libp2r2p 0.10.5 → 0.10.7
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 +63 -0
- package/index.js +1 -0
- package/kind/index.js +2 -0
- package/nip05/helpers/nip05-identifier.js +57 -0
- package/nip05/index.js +6 -8
- package/nip27/helpers/user-reference.js +79 -0
- package/nip27/index.js +360 -0
- package/package.json +3 -1
- package/relay/helpers/routing.js +17 -2
- package/relay/index.js +2 -1
- package/relay/services/events.js +154 -0
- package/relay/services/query.js +116 -38
- package/url/app-url.js +143 -0
- package/url/index.js +28 -14
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
|
|
@@ -341,6 +356,54 @@ Low-level relay sockets, subscriptions, message parsing, and serialization are
|
|
|
341
356
|
internal implementation details; use `RelayPool` or the `relayPool` singleton
|
|
342
357
|
from `libp2r2p/relay`.
|
|
343
358
|
|
|
359
|
+
The same public subpath exports `getRelaysByPubkey(pubkeys)`, which discovers
|
|
360
|
+
the latest NIP-65 relay list for every requested pubkey through `seedRelays`,
|
|
361
|
+
normalizes and deduplicates its public relay URLs, and falls back to the first
|
|
362
|
+
two `freeRelays` when no list is available. Its result can be passed directly
|
|
363
|
+
to `pickRelaysForPubkeys(pubkeys, relaysByPubkey)` to batch authors that share
|
|
364
|
+
read or write relays.
|
|
365
|
+
|
|
366
|
+
`getRelaysByPubkey` accepts a few options:
|
|
367
|
+
|
|
368
|
+
- `includeEvents` returns each pubkey's latest kind `10002` event alongside its
|
|
369
|
+
parsed relays (`{ read, write, event }`), for consumers that need the original
|
|
370
|
+
event (storage, re-signing, freshness tracking).
|
|
371
|
+
- `forceRefresh` re-queries `seedRelays` even when the pubkey is cached, without
|
|
372
|
+
regressing a newer cached event when the relay returns an older one.
|
|
373
|
+
- `timeout` / `timeoutAfterFirstEose` tune the relay-list query timing. The
|
|
374
|
+
default opens a short grace window after the first EOSE with events, matching
|
|
375
|
+
`RelayPool.getEvents`.
|
|
376
|
+
- `emptyRelaysFallback` sets the relays returned when a pubkey has no relay
|
|
377
|
+
list (default: the first two `freeRelays`); pass `[]` to return empty
|
|
378
|
+
`read`/`write` arrays instead, keeping the free-relay decision with the
|
|
379
|
+
caller.
|
|
380
|
+
- `relayUrlPolicy` opts into non-default URL validation: `onion` allows
|
|
381
|
+
`ws://`/`wss://` `.onion` hosts, `localRelay` allows the standardized
|
|
382
|
+
`ws://localhost:4869` local relay, and `nostrEntityUrls` stops rejecting
|
|
383
|
+
public URLs that contain `npub1`/`nprofile1`. All other non-public or
|
|
384
|
+
insecure URLs stay rejected.
|
|
385
|
+
|
|
386
|
+
`parseRelayListEvent(event, relayUrlPolicy)` is exported from the same subpath
|
|
387
|
+
for consumers that parse relay-list events they receive outside
|
|
388
|
+
`getRelaysByPubkey`, so routing and persistence always agree on the parsed
|
|
389
|
+
shape.
|
|
390
|
+
|
|
391
|
+
`pickRelaysForPubkeys` also accepts `excludeRelaysByPubkey` (relays already
|
|
392
|
+
queried per pubkey, as an object or `Map`) and `emptyRelaysFallback` (the
|
|
393
|
+
relays used when a pubkey has no typed relays; pass `[]` to leave that pubkey
|
|
394
|
+
unrouted). Together with `maxPerPubkey: Infinity` these express a second,
|
|
395
|
+
exhaustive pass over the relays that were not yet tried for each author.
|
|
396
|
+
|
|
397
|
+
For callers that want the whole two-pass flow, `getLatestEventsByPubkey`
|
|
398
|
+
fetches the latest replaceable events (or addressable events when
|
|
399
|
+
`dTagsByPubkey` maps pubkeys to their `d` tags) through NIP-65 write relays:
|
|
400
|
+
the first pass routes through up to `maxPerPubkey` shared relays, and missing
|
|
401
|
+
authors are retried on every remaining relay plus `fallbackRelays` (default:
|
|
402
|
+
the first three `freeRelays`), excluding what was already queried. It accepts
|
|
403
|
+
`relaysByPubkey` to reuse a previous discovery (only missing pubkeys are then
|
|
404
|
+
discovered) and returns `{ events, byPubkey, relaysByPubkey }` so the merged
|
|
405
|
+
relay map can be passed back on later calls.
|
|
406
|
+
|
|
344
407
|
## Binary encodings
|
|
345
408
|
|
|
346
409
|
Base16, Base36, Base62, Base64/Base64URL, and Base93 helpers are available
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.10.7",
|
|
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/relay/helpers/routing.js
CHANGED
|
@@ -2,15 +2,30 @@ import { freeRelays } from '../constants/index.js'
|
|
|
2
2
|
|
|
3
3
|
const DEFAULT_RELAYS_PER_PUBKEY = 2
|
|
4
4
|
|
|
5
|
+
function excludedRelaysFor (excludeRelaysByPubkey, pubkey) {
|
|
6
|
+
if (!excludeRelaysByPubkey) return []
|
|
7
|
+
return excludeRelaysByPubkey instanceof Map
|
|
8
|
+
? excludeRelaysByPubkey.get(pubkey) || []
|
|
9
|
+
: excludeRelaysByPubkey[pubkey] || []
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
// Given pubkeys and their relay mappings, picks the minimum set of relays
|
|
6
13
|
// that covers all pubkeys (up to maxPerPubkey relays each), preferring
|
|
7
14
|
// relays shared by more pubkeys. Returns Map<relayUrl, pubkey[]>.
|
|
8
|
-
export function pickRelaysForPubkeys (pubkeys, relaysByPubkey, {
|
|
15
|
+
export function pickRelaysForPubkeys (pubkeys, relaysByPubkey, {
|
|
16
|
+
maxPerPubkey = DEFAULT_RELAYS_PER_PUBKEY,
|
|
17
|
+
relayType = 'write',
|
|
18
|
+
excludeRelaysByPubkey,
|
|
19
|
+
emptyRelaysFallback = freeRelays.slice(0, DEFAULT_RELAYS_PER_PUBKEY)
|
|
20
|
+
} = {}) {
|
|
9
21
|
const type = relayType === 'read' ? 'read' : 'write'
|
|
10
22
|
const pkToPossibleRelays = new Map()
|
|
11
23
|
for (const pk of pubkeys) {
|
|
12
24
|
const relays = relaysByPubkey[pk]?.[type] || []
|
|
13
|
-
|
|
25
|
+
const excluded = new Set(excludedRelaysFor(excludeRelaysByPubkey, pk))
|
|
26
|
+
const candidates = (relays.length ? relays : emptyRelaysFallback)
|
|
27
|
+
.filter(relay => !excluded.has(relay))
|
|
28
|
+
pkToPossibleRelays.set(pk, new Set(candidates))
|
|
14
29
|
}
|
|
15
30
|
|
|
16
31
|
const relayCounts = new Map()
|
package/relay/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { freeRelays, nappRelays, seedRelays } from './constants/index.js'
|
|
2
2
|
export { pickRelaysForPubkeys } from './helpers/routing.js'
|
|
3
3
|
export { RelayPool, relayPool } from './services/relay-pool.js'
|
|
4
|
-
export {
|
|
4
|
+
export { getLatestEventsByPubkey } from './services/events.js'
|
|
5
|
+
export { getRelaysByPubkey, parseRelayListEvent, subscribeRelayListUpdates } from './services/query.js'
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { freeRelays } from '../constants/index.js'
|
|
2
|
+
import { pickRelaysForPubkeys } from '../helpers/routing.js'
|
|
3
|
+
import { relayPool } from './relay-pool.js'
|
|
4
|
+
import { getRelaysByPubkey } from './query.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_PER_PUBKEY = 2
|
|
7
|
+
const DEFAULT_FALLBACK_RELAY_COUNT = 3
|
|
8
|
+
|
|
9
|
+
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
10
|
+
|
|
11
|
+
function hasOwn (object, key) {
|
|
12
|
+
return Object.prototype.hasOwnProperty.call(object, key)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function eventAddress (event) {
|
|
16
|
+
const dTag = event.tags?.find(tag => tag[0] === 'd')
|
|
17
|
+
return `${event.kind}:${event.pubkey}:${dTag?.[1] ?? ''}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// NIP-01 ordering for replaceable and addressable events.
|
|
21
|
+
function isNewerEvent (candidate, current) {
|
|
22
|
+
if (!candidate) return false
|
|
23
|
+
if (!current) return true
|
|
24
|
+
if (candidate.created_at !== current.created_at) return candidate.created_at > current.created_at
|
|
25
|
+
if (typeof candidate.id !== 'string') return false
|
|
26
|
+
return typeof current.id !== 'string' || candidate.id < current.id
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mergeLatestEvents (target, events) {
|
|
30
|
+
for (const event of events) {
|
|
31
|
+
const address = eventAddress(event)
|
|
32
|
+
if (isNewerEvent(event, target[address])) target[address] = event
|
|
33
|
+
}
|
|
34
|
+
return target
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Records which relays were already queried for each pubkey.
|
|
38
|
+
function getSelectedRelaysByPubkey (pubkeys, relayToAuthors) {
|
|
39
|
+
const selected = Object.fromEntries(pubkeys.map(pubkey => [pubkey, new Set()]))
|
|
40
|
+
for (const [relay, authors] of relayToAuthors) {
|
|
41
|
+
for (const pubkey of authors) selected[pubkey]?.add(relay)
|
|
42
|
+
}
|
|
43
|
+
return selected
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fetches batched events and keeps the newest event per address.
|
|
47
|
+
async function fetchLatestEventsByRelay (relayToAuthors, { kinds, dTagsByPubkey, getEvents }) {
|
|
48
|
+
const requests = []
|
|
49
|
+
for (const [relay, authors] of relayToAuthors) {
|
|
50
|
+
const authorsByD = new Map()
|
|
51
|
+
for (const pubkey of authors) {
|
|
52
|
+
const d = dTagsByPubkey?.[pubkey] ?? ''
|
|
53
|
+
if (!authorsByD.has(d)) authorsByD.set(d, [])
|
|
54
|
+
authorsByD.get(d).push(pubkey)
|
|
55
|
+
}
|
|
56
|
+
for (const [d, dAuthors] of authorsByD) {
|
|
57
|
+
const filter = { kinds, authors: dAuthors }
|
|
58
|
+
if (d) filter['#d'] = [d]
|
|
59
|
+
requests.push(getEvents(filter, [relay])
|
|
60
|
+
.then(response => ({ requested: new Set(dAuthors), events: response.result || [] })))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const results = await Promise.allSettled(requests)
|
|
65
|
+
const latestByAddress = {}
|
|
66
|
+
for (const result of results) {
|
|
67
|
+
if (result.status !== 'fulfilled') continue
|
|
68
|
+
const { requested, events } = result.value
|
|
69
|
+
mergeLatestEvents(latestByAddress, events.filter(event =>
|
|
70
|
+
kinds.includes(event?.kind) && requested.has(event?.pubkey)
|
|
71
|
+
))
|
|
72
|
+
}
|
|
73
|
+
return latestByAddress
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fetches the latest replaceable (or addressable, with `dTagsByPubkey`) events
|
|
78
|
+
* for many pubkeys through their NIP-65 relays in two batched passes.
|
|
79
|
+
*
|
|
80
|
+
* The first pass routes through up to `maxPerPubkey` relays per author,
|
|
81
|
+
* preferring relays shared by more authors. Authors still missing after that
|
|
82
|
+
* pass are retried on every remaining relay plus `fallbackRelays`, excluding
|
|
83
|
+
* relays already queried for them.
|
|
84
|
+
*
|
|
85
|
+
* When `relaysByPubkey` is provided, it is reused as-is for the pubkeys it
|
|
86
|
+
* covers; only missing pubkeys are discovered, and the merged map is returned
|
|
87
|
+
* so it can be passed back on a later call.
|
|
88
|
+
*/
|
|
89
|
+
export async function getLatestEventsByPubkey (pubkeys, {
|
|
90
|
+
kinds,
|
|
91
|
+
dTagsByPubkey,
|
|
92
|
+
relayType = 'write',
|
|
93
|
+
maxPerPubkey = DEFAULT_MAX_PER_PUBKEY,
|
|
94
|
+
fallbackRelays = freeRelays.slice(0, DEFAULT_FALLBACK_RELAY_COUNT),
|
|
95
|
+
relaysByPubkey,
|
|
96
|
+
relayListOptions,
|
|
97
|
+
_getRelaysByPubkey = getRelaysByPubkey,
|
|
98
|
+
_getEvents = getEvents
|
|
99
|
+
} = {}) {
|
|
100
|
+
const authors = [...new Set(pubkeys || [])].filter(Boolean)
|
|
101
|
+
if (!authors.length) return { events: [], byPubkey: {}, relaysByPubkey: {} }
|
|
102
|
+
if (!Array.isArray(kinds) || kinds.length === 0) throw new Error('Missing kinds')
|
|
103
|
+
const type = relayType === 'read' ? 'read' : 'write'
|
|
104
|
+
|
|
105
|
+
const relaysByAuthor = { ...(relaysByPubkey || {}) }
|
|
106
|
+
const missingRelayAuthors = authors.filter(pubkey => !hasOwn(relaysByAuthor, pubkey))
|
|
107
|
+
if (missingRelayAuthors.length) {
|
|
108
|
+
let discovered
|
|
109
|
+
try {
|
|
110
|
+
discovered = await _getRelaysByPubkey(missingRelayAuthors, relayListOptions)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Failed to discover publisher relays:', error)
|
|
113
|
+
discovered = Object.fromEntries(missingRelayAuthors.map(pubkey => [pubkey, { read: [], write: [] }]))
|
|
114
|
+
}
|
|
115
|
+
for (const pubkey of missingRelayAuthors) {
|
|
116
|
+
relaysByAuthor[pubkey] = discovered?.[pubkey] || { read: [], write: [] }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const primaryAuthors = authors.filter(pubkey => relaysByAuthor[pubkey]?.[type]?.length)
|
|
121
|
+
const primaryRoutes = pickRelaysForPubkeys(primaryAuthors, relaysByAuthor, { maxPerPubkey, relayType })
|
|
122
|
+
const selectedRelays = getSelectedRelaysByPubkey(authors, primaryRoutes)
|
|
123
|
+
const latestByAddress = await fetchLatestEventsByRelay(primaryRoutes, {
|
|
124
|
+
kinds,
|
|
125
|
+
dTagsByPubkey,
|
|
126
|
+
getEvents: _getEvents
|
|
127
|
+
})
|
|
128
|
+
const foundPubkeys = new Set(Object.values(latestByAddress).map(event => event.pubkey))
|
|
129
|
+
const missingAuthors = authors.filter(pubkey => !foundPubkeys.has(pubkey))
|
|
130
|
+
|
|
131
|
+
if (missingAuthors.length) {
|
|
132
|
+
const fallbackRelaysByPubkey = Object.fromEntries(missingAuthors.map(pubkey => [
|
|
133
|
+
pubkey, { [type]: [...new Set([...(relaysByAuthor[pubkey]?.[type] || []), ...fallbackRelays])] }
|
|
134
|
+
]))
|
|
135
|
+
const fallbackRoutes = pickRelaysForPubkeys(missingAuthors, fallbackRelaysByPubkey, {
|
|
136
|
+
maxPerPubkey: Infinity,
|
|
137
|
+
relayType,
|
|
138
|
+
excludeRelaysByPubkey: selectedRelays,
|
|
139
|
+
emptyRelaysFallback: []
|
|
140
|
+
})
|
|
141
|
+
mergeLatestEvents(latestByAddress, Object.values(
|
|
142
|
+
await fetchLatestEventsByRelay(fallbackRoutes, {
|
|
143
|
+
kinds,
|
|
144
|
+
dTagsByPubkey,
|
|
145
|
+
getEvents: _getEvents
|
|
146
|
+
})
|
|
147
|
+
))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const events = Object.values(latestByAddress)
|
|
151
|
+
const byPubkey = {}
|
|
152
|
+
for (const event of events) byPubkey[event.pubkey] = event
|
|
153
|
+
return { events, byPubkey, relaysByPubkey: relaysByAuthor }
|
|
154
|
+
}
|
package/relay/services/query.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { freeRelays, seedRelays } from '../constants/index.js'
|
|
2
2
|
import { relayPool } from './relay-pool.js'
|
|
3
|
+
import { isValidPublicRelayUrl, normalizeRelayUrl } from '../../url/index.js'
|
|
3
4
|
|
|
4
5
|
const QUERY_CACHE_MS = 40 * 60 * 1000
|
|
5
6
|
const RELAY_CACHE_MAX_ITEMS = 500
|
|
@@ -8,9 +9,14 @@ const relaysByPubkey = Object.create(null)
|
|
|
8
9
|
const relayCacheTimersByPubkey = Object.create(null)
|
|
9
10
|
const relayCacheAddedAtByPubkey = Object.create(null)
|
|
10
11
|
const relayCacheEventCreatedAtByPubkey = Object.create(null)
|
|
12
|
+
const relayCacheEventIdByPubkey = Object.create(null)
|
|
13
|
+
const relayCacheEventByPubkey = Object.create(null)
|
|
14
|
+
const relayRequestsByPubkey = new Map()
|
|
11
15
|
|
|
12
16
|
const getEvents = (...args) => relayPool.getEvents(...args)
|
|
13
17
|
const getEventsFeedGenerator = (...args) => relayPool.getEventsFeedGenerator(...args)
|
|
18
|
+
const RELAY_LIST_QUERY_TIMEOUT_MS = 5000
|
|
19
|
+
const RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS = 500
|
|
14
20
|
|
|
15
21
|
function hasCachedKey (cache, key) {
|
|
16
22
|
return Object.prototype.hasOwnProperty.call(cache, key)
|
|
@@ -28,15 +34,27 @@ function cloneRelays (relays) {
|
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
function cloneRelayListEvent (event) {
|
|
38
|
+
if (!event) return null
|
|
39
|
+
return { ...event, tags: [...(event.tags || [])] }
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
// NIP-65 relay-list tags without a marker apply to both read and write use.
|
|
32
|
-
function parseRelayListEvent (event) {
|
|
43
|
+
export function parseRelayListEvent (event, relayUrlPolicy) {
|
|
33
44
|
const out = { read: [], write: [] }
|
|
34
45
|
if (!event || event.kind !== 10002) return out
|
|
35
|
-
for (const tag of event.tags) {
|
|
46
|
+
for (const tag of event.tags || []) {
|
|
36
47
|
if (tag[0] !== 'r' || typeof tag[1] !== 'string') continue
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
let relay
|
|
49
|
+
try {
|
|
50
|
+
relay = normalizeRelayUrl(tag[1])
|
|
51
|
+
} catch {
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (!isValidPublicRelayUrl(relay, relayUrlPolicy)) continue
|
|
55
|
+
if (tag[2] === 'read') out.read.push(relay)
|
|
56
|
+
else if (tag[2] === 'write') out.write.push(relay)
|
|
57
|
+
else { out.read.push(relay); out.write.push(relay) }
|
|
40
58
|
}
|
|
41
59
|
out.read = [...new Set(out.read)]
|
|
42
60
|
out.write = [...new Set(out.write)]
|
|
@@ -52,6 +70,16 @@ function relayListCreatedAt (event) {
|
|
|
52
70
|
return Number.isFinite(event?.created_at) ? event.created_at : 0
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
function isNewerRelayListEvent (candidate, current) {
|
|
74
|
+
if (!candidate) return false
|
|
75
|
+
if (!current) return true
|
|
76
|
+
const candidateCreatedAt = relayListCreatedAt(candidate)
|
|
77
|
+
const currentCreatedAt = relayListCreatedAt(current)
|
|
78
|
+
if (candidateCreatedAt !== currentCreatedAt) return candidateCreatedAt > currentCreatedAt
|
|
79
|
+
if (typeof candidate.id !== 'string') return false
|
|
80
|
+
return typeof current.id !== 'string' || candidate.id < current.id
|
|
81
|
+
}
|
|
82
|
+
|
|
55
83
|
function areRelaySetsEqual (a, b) {
|
|
56
84
|
const left = new Set(a || [])
|
|
57
85
|
const right = new Set(b || [])
|
|
@@ -82,12 +110,16 @@ function deleteCachedRelay (pubkey) {
|
|
|
82
110
|
delete relayCacheTimersByPubkey[pubkey]
|
|
83
111
|
delete relayCacheAddedAtByPubkey[pubkey]
|
|
84
112
|
delete relayCacheEventCreatedAtByPubkey[pubkey]
|
|
113
|
+
delete relayCacheEventIdByPubkey[pubkey]
|
|
114
|
+
delete relayCacheEventByPubkey[pubkey]
|
|
85
115
|
}
|
|
86
116
|
|
|
87
|
-
function setCachedRelays (pubkey, relays,
|
|
117
|
+
function setCachedRelays (pubkey, relays, event, cacheMs) {
|
|
88
118
|
relaysByPubkey[pubkey] = cloneRelays(relays)
|
|
89
119
|
relayCacheAddedAtByPubkey[pubkey] = Date.now()
|
|
90
|
-
relayCacheEventCreatedAtByPubkey[pubkey] =
|
|
120
|
+
relayCacheEventCreatedAtByPubkey[pubkey] = relayListCreatedAt(event)
|
|
121
|
+
relayCacheEventIdByPubkey[pubkey] = typeof event?.id === 'string' ? event.id : null
|
|
122
|
+
relayCacheEventByPubkey[pubkey] = event || null
|
|
91
123
|
clearTimeout(relayCacheTimersByPubkey[pubkey])
|
|
92
124
|
if (cacheMs > 0) {
|
|
93
125
|
relayCacheTimersByPubkey[pubkey] = maybeUnref(setTimeout(() => {
|
|
@@ -114,20 +146,24 @@ export function clearRelayQueryCache () {
|
|
|
114
146
|
for (const key of Object.keys(relayCacheTimersByPubkey)) delete relayCacheTimersByPubkey[key]
|
|
115
147
|
for (const key of Object.keys(relayCacheAddedAtByPubkey)) delete relayCacheAddedAtByPubkey[key]
|
|
116
148
|
for (const key of Object.keys(relayCacheEventCreatedAtByPubkey)) delete relayCacheEventCreatedAtByPubkey[key]
|
|
149
|
+
for (const key of Object.keys(relayCacheEventIdByPubkey)) delete relayCacheEventIdByPubkey[key]
|
|
150
|
+
for (const key of Object.keys(relayCacheEventByPubkey)) delete relayCacheEventByPubkey[key]
|
|
117
151
|
}
|
|
118
152
|
|
|
119
|
-
export function cacheRelayListEvent (event, { cacheMs = QUERY_CACHE_MS } = {}) {
|
|
153
|
+
export function cacheRelayListEvent (event, { cacheMs = QUERY_CACHE_MS, relayUrlPolicy } = {}) {
|
|
120
154
|
if (!event || event.kind !== 10002 || !event.pubkey) return null
|
|
121
|
-
const createdAt = relayListCreatedAt(event)
|
|
122
155
|
const previousCreatedAt = relayCacheEventCreatedAtByPubkey[event.pubkey]
|
|
123
|
-
|
|
156
|
+
const previousEvent = previousCreatedAt == null
|
|
157
|
+
? null
|
|
158
|
+
: { created_at: previousCreatedAt, id: relayCacheEventIdByPubkey[event.pubkey] }
|
|
159
|
+
if (!isNewerRelayListEvent(event, previousEvent)) return null
|
|
124
160
|
|
|
125
161
|
const previousRelays = hasCachedKey(relaysByPubkey, event.pubkey)
|
|
126
162
|
? cloneRelays(relaysByPubkey[event.pubkey])
|
|
127
163
|
: null
|
|
128
|
-
const relays = parseRelayListEvent(event)
|
|
164
|
+
const relays = parseRelayListEvent(event, relayUrlPolicy)
|
|
129
165
|
const changes = relaySetChanges(previousRelays, relays)
|
|
130
|
-
setCachedRelays(event.pubkey, relays,
|
|
166
|
+
setCachedRelays(event.pubkey, relays, event, cacheMs)
|
|
131
167
|
pruneRelayCache()
|
|
132
168
|
|
|
133
169
|
return {
|
|
@@ -144,6 +180,7 @@ export function subscribeRelayListUpdates (pubkeys, {
|
|
|
144
180
|
onChange,
|
|
145
181
|
relays = seedRelays,
|
|
146
182
|
cacheMs = QUERY_CACHE_MS,
|
|
183
|
+
relayUrlPolicy,
|
|
147
184
|
_eventsFeedGenerator = getEventsFeedGenerator
|
|
148
185
|
} = {}) {
|
|
149
186
|
const authors = uniquePubkeys(pubkeys, { requireHex: _eventsFeedGenerator === getEventsFeedGenerator })
|
|
@@ -163,7 +200,7 @@ export function subscribeRelayListUpdates (pubkeys, {
|
|
|
163
200
|
timeoutAfterFirstEose: null
|
|
164
201
|
})) {
|
|
165
202
|
if (closed || !authors.includes(event.pubkey)) continue
|
|
166
|
-
const update = cacheRelayListEvent(event, { cacheMs })
|
|
203
|
+
const update = cacheRelayListEvent(event, { cacheMs, relayUrlPolicy })
|
|
167
204
|
if (!update || !relayTypeChanged(update.changes, relayType)) continue
|
|
168
205
|
onChange?.({
|
|
169
206
|
...update,
|
|
@@ -183,42 +220,83 @@ export function subscribeRelayListUpdates (pubkeys, {
|
|
|
183
220
|
}
|
|
184
221
|
}
|
|
185
222
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
if (!missingPubkeys.length) return out
|
|
197
|
-
|
|
198
|
-
const { result: events } = await _getEvents({
|
|
223
|
+
async function loadMissingRelays (missingPubkeys, {
|
|
224
|
+
getEvents,
|
|
225
|
+
cacheMs,
|
|
226
|
+
timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
|
|
227
|
+
timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
|
|
228
|
+
relayUrlPolicy,
|
|
229
|
+
emptyRelaysFallback = freeRelays.slice(0, 2)
|
|
230
|
+
}) {
|
|
231
|
+
const { result: events } = await getEvents({
|
|
199
232
|
kinds: [10002],
|
|
200
233
|
authors: missingPubkeys,
|
|
201
234
|
limit: missingPubkeys.length
|
|
202
235
|
}, seedRelays, {
|
|
203
|
-
timeout
|
|
204
|
-
timeoutAfterFirstEose
|
|
236
|
+
timeout,
|
|
237
|
+
timeoutAfterFirstEose
|
|
205
238
|
})
|
|
206
239
|
|
|
207
240
|
const latestByPubkey = {}
|
|
208
|
-
for (const event of events) {
|
|
241
|
+
for (const event of events || []) {
|
|
209
242
|
if (!missingPubkeys.includes(event.pubkey)) continue
|
|
210
|
-
if (
|
|
211
|
-
latestByPubkey[event.pubkey] = event
|
|
212
|
-
}
|
|
243
|
+
if (isNewerRelayListEvent(event, latestByPubkey[event.pubkey])) latestByPubkey[event.pubkey] = event
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
for (const pubkey of missingPubkeys) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
247
|
+
const fetchedEvent = latestByPubkey[pubkey]
|
|
248
|
+
const cachedEvent = relayCacheEventByPubkey[pubkey] || null
|
|
249
|
+
const event = isNewerRelayListEvent(fetchedEvent, cachedEvent) ? fetchedEvent : cachedEvent
|
|
250
|
+
const relays = event
|
|
251
|
+
? parseRelayListEvent(event, relayUrlPolicy)
|
|
252
|
+
: { read: [...emptyRelaysFallback], write: [...emptyRelaysFallback] }
|
|
253
|
+
setCachedRelays(pubkey, relays, event, cacheMs)
|
|
221
254
|
}
|
|
222
255
|
pruneRelayCache()
|
|
223
|
-
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function getRelaysByPubkey (pubkeys, {
|
|
259
|
+
_getEvents = getEvents,
|
|
260
|
+
cacheMs = QUERY_CACHE_MS,
|
|
261
|
+
includeEvents = false,
|
|
262
|
+
forceRefresh = false,
|
|
263
|
+
timeout = RELAY_LIST_QUERY_TIMEOUT_MS,
|
|
264
|
+
timeoutAfterFirstEose = RELAY_LIST_QUERY_TIMEOUT_AFTER_FIRST_EOSE_MS,
|
|
265
|
+
relayUrlPolicy,
|
|
266
|
+
emptyRelaysFallback = freeRelays.slice(0, 2)
|
|
267
|
+
} = {}) {
|
|
268
|
+
const pubkeyList = uniquePubkeys(pubkeys, { requireHex: _getEvents === getEvents })
|
|
269
|
+
if (!pubkeyList.length) return {}
|
|
270
|
+
|
|
271
|
+
const loadPubkeys = forceRefresh
|
|
272
|
+
? pubkeyList
|
|
273
|
+
: pubkeyList.filter(pubkey => !hasCachedKey(relaysByPubkey, pubkey))
|
|
274
|
+
const pubkeysToLoad = loadPubkeys.filter(pubkey => !relayRequestsByPubkey.has(pubkey))
|
|
275
|
+
if (pubkeysToLoad.length) {
|
|
276
|
+
const request = loadMissingRelays(pubkeysToLoad, {
|
|
277
|
+
getEvents: _getEvents,
|
|
278
|
+
cacheMs,
|
|
279
|
+
timeout,
|
|
280
|
+
timeoutAfterFirstEose,
|
|
281
|
+
relayUrlPolicy,
|
|
282
|
+
emptyRelaysFallback
|
|
283
|
+
}).finally(() => {
|
|
284
|
+
for (const pubkey of pubkeysToLoad) {
|
|
285
|
+
if (relayRequestsByPubkey.get(pubkey) === request) relayRequestsByPubkey.delete(pubkey)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
for (const pubkey of pubkeysToLoad) relayRequestsByPubkey.set(pubkey, request)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await Promise.all([...new Set(
|
|
292
|
+
loadPubkeys.map(pubkey => relayRequestsByPubkey.get(pubkey)).filter(Boolean)
|
|
293
|
+
)])
|
|
294
|
+
|
|
295
|
+
return Object.fromEntries(pubkeyList
|
|
296
|
+
.filter(pubkey => hasCachedKey(relaysByPubkey, pubkey))
|
|
297
|
+
.map(pubkey => {
|
|
298
|
+
const entry = cloneRelays(relaysByPubkey[pubkey])
|
|
299
|
+
if (includeEvents) entry.event = cloneRelayListEvent(relayCacheEventByPubkey[pubkey])
|
|
300
|
+
return [pubkey, entry]
|
|
301
|
+
}))
|
|
224
302
|
}
|
package/url/app-url.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
} from '../nip19/index.js'
|
|
14
|
+
|
|
15
|
+
export const APP_URL_MIN_ENTITY_BODY_LENGTH = 48
|
|
16
|
+
|
|
17
|
+
const APP_NAME_MAX_LENGTH = 260
|
|
18
|
+
const CHANNEL_BY_PREFIX = { '+': 'main', '++': 'next', '+++': 'draft' }
|
|
19
|
+
const PREFIX_BY_CHANNEL = { main: '+', next: '++', draft: '+++' }
|
|
20
|
+
const KIND_BY_CHANNEL = { main: 35128, next: 35129, draft: 35130 }
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
23
|
+
|
|
24
|
+
function safeDecode (value) {
|
|
25
|
+
try {
|
|
26
|
+
return decodeURIComponent(value)
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isValidAppName (appName) {
|
|
33
|
+
return typeof appName === 'string' &&
|
|
34
|
+
appName.length > 0 &&
|
|
35
|
+
appName.length <= APP_NAME_MAX_LENGTH &&
|
|
36
|
+
!appName.startsWith('+') &&
|
|
37
|
+
!appName.includes('/') &&
|
|
38
|
+
!CONTROL_CHARS.test(appName)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Decodes a raw (still percent-encoded) first path segment such as
|
|
42
|
+
// `+apps` or `+caf%C3%A9@bob@example.com` or `+3swFhu...`.
|
|
43
|
+
// Returns:
|
|
44
|
+
// - `{ type: 'entity', entity }` for NIP-19 app entities;
|
|
45
|
+
// - `{ type: 'named', prefix, channel, appName, user }` for named URLs
|
|
46
|
+
// (`user` is null when no user part is present or it is invalid);
|
|
47
|
+
// - `null` when the segment is not a valid app URL.
|
|
48
|
+
export function decodeAppUrl (segment) {
|
|
49
|
+
if (typeof segment !== 'string' || !segment) return null
|
|
50
|
+
const prefixMatch = segment.match(/^\+{1,3}/)
|
|
51
|
+
if (!prefixMatch) return null
|
|
52
|
+
const prefix = prefixMatch[0]
|
|
53
|
+
|
|
54
|
+
if (NAPP_ENTITY_REGEX.test(segment)) {
|
|
55
|
+
try {
|
|
56
|
+
appDecode(segment)
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
return { type: 'entity', entity: segment }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const remainder = segment.slice(prefix.length)
|
|
64
|
+
if (!remainder) return null
|
|
65
|
+
const parts = remainder.split('@')
|
|
66
|
+
let appName
|
|
67
|
+
let user = null
|
|
68
|
+
|
|
69
|
+
if (parts.length === 1) {
|
|
70
|
+
appName = safeDecode(parts[0])
|
|
71
|
+
} else if (parts.length === 2) {
|
|
72
|
+
const tail = safeDecode(parts[1])
|
|
73
|
+
appName = safeDecode(parts[0])
|
|
74
|
+
user = tail === null ? null : decodeUserReference(tail)
|
|
75
|
+
} else {
|
|
76
|
+
const local = safeDecode(parts[parts.length - 2])
|
|
77
|
+
const domain = safeDecode(parts[parts.length - 1])
|
|
78
|
+
const nip05 = nip05FromLocalDomain(local, domain)
|
|
79
|
+
if (nip05) {
|
|
80
|
+
user = { kind: 'nip05', ...nip05, raw: compactNip05Raw(local, domain) }
|
|
81
|
+
appName = parts.slice(0, -2).map(safeDecode).join('@')
|
|
82
|
+
} else {
|
|
83
|
+
appName = safeDecode(remainder)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (appName === null || !isValidAppName(appName)) return null
|
|
88
|
+
if (!user && appName.length >= APP_URL_MIN_ENTITY_BODY_LENGTH) return null
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type: 'named',
|
|
92
|
+
prefix,
|
|
93
|
+
channel: CHANNEL_BY_PREFIX[prefix],
|
|
94
|
+
appName,
|
|
95
|
+
user
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Encodes a named app URL segment. `user` is a decoded user reference:
|
|
100
|
+
// NIP-05 (`bob@example.com`, `_@example.com`, `example.com` or the custom
|
|
101
|
+
// `bob.xyz.example.com` form), npub, nprofile or hex pubkey.
|
|
102
|
+
export function encodeAppUrl ({ appName, channel = 'main', user }) {
|
|
103
|
+
if (!isValidAppName(appName)) {
|
|
104
|
+
throw new ValidationError('INVALID_APP_URL_NAME', { message: 'Invalid app URL name' })
|
|
105
|
+
}
|
|
106
|
+
const prefix = PREFIX_BY_CHANNEL[channel]
|
|
107
|
+
if (!prefix) {
|
|
108
|
+
throw new ValidationError('INVALID_APP_URL_CHANNEL', { message: 'Invalid app URL channel' })
|
|
109
|
+
}
|
|
110
|
+
const userRef = decodeUserReference(user)
|
|
111
|
+
if (!userRef) {
|
|
112
|
+
throw new ValidationError('INVALID_APP_URL_USER', { message: 'Invalid app URL user' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let encodedAppName = encodeURIComponent(appName)
|
|
116
|
+
let userText
|
|
117
|
+
if (userRef.kind === 'nip05') {
|
|
118
|
+
// Keep `@` raw inside the app name so the verbose NIP-05 form stays
|
|
119
|
+
// readable (`+my@app@bob@example.com`).
|
|
120
|
+
encodedAppName = encodedAppName.replace(/%40/g, '@')
|
|
121
|
+
const appNameHasAt = appName.includes('@')
|
|
122
|
+
if (userRef.local === '_') {
|
|
123
|
+
userText = userRef.domain.split('.').length === 2 && !appNameHasAt
|
|
124
|
+
? userRef.domain
|
|
125
|
+
: `_@${userRef.domain}`
|
|
126
|
+
} else {
|
|
127
|
+
// Prefer the shorter single-`@` custom NIP-05 form (`bob.example.com`)
|
|
128
|
+
// unless the app name itself contains `@`, which forces the verbose form.
|
|
129
|
+
userText = appNameHasAt
|
|
130
|
+
? `${userRef.local}@${userRef.domain}`
|
|
131
|
+
: `${userRef.local}.${userRef.domain}`
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
userText = encodeUserReference(userRef)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return `${prefix}${encodedAppName}@${userText}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Convenience for callers that need the manifest kind for a decoded channel.
|
|
141
|
+
export function appUrlKindByChannel (channel) {
|
|
142
|
+
return KIND_BY_CHANNEL[channel] || null
|
|
143
|
+
}
|
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 =>
|
|
@@ -130,7 +132,14 @@ export function normalizeBlossomServerUrl (value) {
|
|
|
130
132
|
return url.origin
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
function
|
|
135
|
+
function isPolicyRelayUrl (url, policy) {
|
|
136
|
+
const hostname = url.hostname.toLowerCase().replace(/\.+$/, '')
|
|
137
|
+
if (policy?.onion && hostname.endsWith('.onion')) return true
|
|
138
|
+
if (policy?.localRelay && hostname === 'localhost' && url.port === '4869' && (url.pathname === '/' || url.pathname === '')) return true
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function publicRelayUrlError (value, policy = {}) {
|
|
134
143
|
if (typeof value !== 'string' || value.trim().length === 0) return 'INVALID_RELAY_URL'
|
|
135
144
|
let input = value.trim()
|
|
136
145
|
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(input)) input = `wss://${input}`
|
|
@@ -148,28 +157,33 @@ function publicRelayUrlError (value) {
|
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
const url = new URL(normalized)
|
|
151
|
-
|
|
160
|
+
const policyRelay = isPolicyRelayUrl(url, policy)
|
|
161
|
+
if (url.protocol !== 'wss:' && !policyRelay) return 'INSECURE_RELAY_URL'
|
|
152
162
|
if (url.username || url.password) return 'RELAY_URL_CREDENTIALS_NOT_ALLOWED'
|
|
153
163
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
if (!policyRelay) {
|
|
165
|
+
const hostError = publicHostError(url, {
|
|
166
|
+
invalidHostCode: 'INVALID_RELAY_HOST',
|
|
167
|
+
nonPublicHostCode: 'NON_PUBLIC_RELAY_HOST',
|
|
168
|
+
nonPublicIpCode: 'NON_PUBLIC_RELAY_IP'
|
|
169
|
+
})
|
|
170
|
+
if (hostError) return hostError
|
|
171
|
+
}
|
|
160
172
|
|
|
161
|
-
|
|
162
|
-
|
|
173
|
+
if (!policy?.nostrEntityUrls) {
|
|
174
|
+
const lowerValue = normalized.toLowerCase()
|
|
175
|
+
if (lowerValue.includes('npub1') || lowerValue.includes('nprofile1')) return 'RELAY_URL_NOSTR_ENTITY_NOT_ALLOWED'
|
|
176
|
+
}
|
|
163
177
|
|
|
164
178
|
return null
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
export function isValidPublicRelayUrl (value) {
|
|
168
|
-
return publicRelayUrlError(value) === null
|
|
181
|
+
export function isValidPublicRelayUrl (value, policy) {
|
|
182
|
+
return publicRelayUrlError(value, policy) === null
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
export function assertValidPublicRelayUrl (value) {
|
|
172
|
-
const code = publicRelayUrlError(value)
|
|
185
|
+
export function assertValidPublicRelayUrl (value, policy) {
|
|
186
|
+
const code = publicRelayUrlError(value, policy)
|
|
173
187
|
if (code) throw new ValidationError(code)
|
|
174
188
|
return value
|
|
175
189
|
}
|