diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx
index 3dcc7e20..ce0a3460 100644
--- a/src/components/RelayIcon/index.tsx
+++ b/src/components/RelayIcon/index.tsx
@@ -3,12 +3,13 @@ import { useFetchRelayInfo } from '@/hooks'
import { useRelaySessionStrikeActive } from '@/hooks/useRelaySessionStrikeActive'
import {
getRelayIconFallbackGlyph,
+ getRelayIconLucideFallback,
getRelayIconOverrideSrc,
relayUrlFingerprintColors
} from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils'
import type { TRelayInfo } from '@/types'
-import { Server } from 'lucide-react'
+import { Home, Search, Server } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
/**
@@ -59,8 +60,10 @@ export default function RelayIcon({
useEffect(() => {
setIconLoadFailed(false)
}, [url, relayInfo?.icon])
+ const lucideFallback = useMemo(() => getRelayIconLucideFallback(url), [url])
+
const iconUrl = useMemo(() => {
- if (!url) return undefined
+ if (!url || lucideFallback) return undefined
const override = getRelayIconOverrideSrc(url)
if (override) {
@@ -75,7 +78,7 @@ export default function RelayIcon({
}
return undefined
- }, [url, relayInfo])
+ }, [url, relayInfo, lucideFallback])
const fallbackColors = useMemo(() => relayUrlFingerprintColors(url), [url])
const fallbackGlyph = useMemo(() => getRelayIconFallbackGlyph(url), [url])
@@ -95,7 +98,11 @@ export default function RelayIcon({
className="bg-transparent"
style={{ backgroundColor: fallbackColors.background, color: fallbackColors.color }}
>
- {fallbackGlyph ? (
+ {lucideFallback === 'search' ? (
+
+ ) : lucideFallback === 'home' ? (
+
+ ) : fallbackGlyph ? (
{
+ it('fails on nostr.land-style empty full document', () => {
+ const base = { isVerified: false, nip05Name: 'silberengel', nip05Domain: 'nostr.land' }
+ const out = verifyNip05AgainstWellKnown(
+ { names: {}, relays: {} },
+ 'silberengel',
+ SILBERENGEL_HEX,
+ base
+ )
+ expect(out.isVerified).toBe(false)
+ })
+
+ it('verifies with name-scoped document (nostr.land ?name= response)', () => {
+ const base = { isVerified: false, nip05Name: 'silberengel', nip05Domain: 'nostr.land' }
+ const out = verifyNip05AgainstWellKnown(
+ {
+ names: { silberengel: SILBERENGEL_HEX },
+ relays: { silberengel: ['wss://nostr.land'] }
+ },
+ 'silberengel',
+ SILBERENGEL_HEX,
+ base
+ )
+ expect(out.isVerified).toBe(true)
+ expect(out.relays).toEqual(['wss://nostr.land'])
+ })
+})
+
+describe('getWellKnownNip05Url', () => {
+ it('appends name query per NIP-05', () => {
+ expect(getWellKnownNip05Url('nostr.land', 'silberengel')).toBe(
+ 'https://nostr.land/.well-known/nostr.json?name=silberengel'
+ )
+ })
+})
+
describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN)
diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts
index 12656bf2..056d14c6 100644
--- a/src/lib/nip05.ts
+++ b/src/lib/nip05.ts
@@ -18,7 +18,7 @@ type TVerifyNip05Result = {
}
/** Bumps when verification rules change so LRU does not serve stale false negatives. */
-const VERIFY_CACHE_SCHEMA = 7
+const VERIFY_CACHE_SCHEMA = 8
/** Bumps when well-known fetch/parse rules change so stale empty cache entries are not reused. */
const WELL_KNOWN_CACHE_SCHEMA = 5
@@ -239,23 +239,19 @@ const verifyNip05ResultCache = new LRUCache({
}
})
-async function _verifyNip05(nip05: string, pubkey: string): Promise {
- const nip05Str = asNip05LookupString(nip05).trim()
- const split = splitNip05Identifier(nip05Str)
- const nip05Name = split?.name ?? ''
- const nip05Domain = split?.domain ?? ''
- const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
- const userHex = normalizePubkeyForNip05Lookup(pubkey)
- if (!split || !userHex) return result
-
- const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name)
- if (!json) return result
+export function verifyNip05AgainstWellKnown(
+ json: Record | null,
+ nip05Name: string,
+ userHex: string,
+ base: TVerifyNip05Result
+): TVerifyNip05Result {
+ if (!json) return base
const names = json.names as Record | undefined
- if (!names || typeof names !== 'object') return result
+ if (!names || typeof names !== 'object') return base
const resolved = resolveNamesEntry(names, nip05Name, userHex)
- if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return result
+ if (!resolved || !hexPubkeysEqual(resolved.pubkey, userHex)) return base
const relays = json.relays as Record | undefined
const relayList = pickRelayListForPubkey(
@@ -266,12 +262,34 @@ async function _verifyNip05(nip05: string, pubkey: string): Promise {
+ const nip05Str = asNip05LookupString(nip05).trim()
+ const split = splitNip05Identifier(nip05Str)
+ const nip05Name = split?.name ?? ''
+ const nip05Domain = split?.domain ?? ''
+ const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
+ const userHex = normalizePubkeyForNip05Lookup(pubkey)
+ if (!split || !userHex) return result
+
+ const fullDoc = await getOrFetchWellKnownJsonForDomain(nip05Domain)
+ let verified = verifyNip05AgainstWellKnown(fullDoc, nip05Name, userHex, result)
+ if (verified.isVerified) return verified
+
+ // NIP-05: dynamic hosts (e.g. nostr.land) only include the user when `?name=` is set.
+ // Do not cache these responses — they are not a full domain listing.
+ if (nip05Name) {
+ const scoped = await fetchWellKnownNostrJsonOnce(nip05Domain, nip05Name)
+ verified = verifyNip05AgainstWellKnown(scoped, nip05Name, userHex, result)
+ }
+ return verified
+}
+
export async function verifyNip05(nip05: string, pubkey: string): Promise {
const nip05Str = asNip05LookupString(nip05).trim()
const pubkeyNorm = normalizePubkeyForNip05Lookup(pubkey) ?? pubkey.trim()
@@ -406,7 +424,7 @@ async function fetchWellKnownNostrJsonOnce(
return null
}
-/** Always fetch the full domain document (never `?name=` — partial responses must not poison domain cache). */
+/** Full domain document for listing/cache (no `?name=` — partial responses must not poison domain cache). */
async function fetchWellKnownFullDocument(domain: string): Promise | null> {
return fetchWellKnownNostrJsonOnce(domain, undefined)
}
@@ -437,8 +455,8 @@ async function getOrFetchWellKnownJsonForDomain(
return inflight
}
-/** Fetch `/.well-known/nostr.json` for a domain (full document; `name` is resolved locally). */
-async function fetchWellKnownNostrJson(domain: string, _name?: string): Promise | null> {
+/** Fetch full `/.well-known/nostr.json` for a domain (domain listing / relay discovery). */
+async function fetchWellKnownNostrJson(domain: string): Promise | null> {
return getOrFetchWellKnownJsonForDomain(domain)
}
diff --git a/src/lib/relay-icon-source.test.ts b/src/lib/relay-icon-source.test.ts
index 6f427d03..897dbd91 100644
--- a/src/lib/relay-icon-source.test.ts
+++ b/src/lib/relay-icon-source.test.ts
@@ -2,7 +2,9 @@ import { NOSTR_ARCHIVES_SEARCH_RELAY_URL } from '@/constants'
import { describe, expect, it } from 'vitest'
import {
getRelayIconFallbackGlyph,
+ getRelayIconLucideFallback,
getRelayIconOverrideSrc,
+ isLoopbackRelayUrl,
NOSTRARCHIVES_SITE_ICON_SRC
} from '@/lib/relay-icon-source'
@@ -20,4 +22,18 @@ describe('relay icon branding', () => {
it('uses nostrarchives favicon for search relay (same as trending)', () => {
expect(getRelayIconOverrideSrc(NOSTR_ARCHIVES_SEARCH_RELAY_URL)).toBe(NOSTRARCHIVES_SITE_ICON_SRC)
})
+
+ it('uses search lucide fallback for search.nos.today', () => {
+ expect(getRelayIconLucideFallback('wss://search.nos.today/')).toBe('search')
+ expect(getRelayIconFallbackGlyph('wss://search.nos.today/')).toBeUndefined()
+ })
+
+ it('uses home lucide fallback for loopback relays', () => {
+ expect(isLoopbackRelayUrl('ws://localhost:4869/')).toBe(true)
+ expect(isLoopbackRelayUrl('ws://127.0.0.1:4869/')).toBe(true)
+ expect(getRelayIconLucideFallback('ws://localhost:4869/')).toBe('home')
+ expect(getRelayIconLucideFallback('ws://127.0.0.1:4869/')).toBe('home')
+ expect(isLoopbackRelayUrl('wss://192.168.0.5/')).toBe(false)
+ expect(getRelayIconLucideFallback('wss://192.168.0.5/')).toBeUndefined()
+ })
})
diff --git a/src/lib/relay-icon-source.ts b/src/lib/relay-icon-source.ts
index 23eb9c67..4ed6ddff 100644
--- a/src/lib/relay-icon-source.ts
+++ b/src/lib/relay-icon-source.ts
@@ -36,6 +36,8 @@ export function isNostrArchivesBrandedRelayUrl(url: string | undefined): boolean
)
}
+export type RelayIconLucideFallback = 'search' | 'home'
+
function parseRelayHostname(url: string): string | undefined {
const raw = (normalizeUrl(url) || url).trim()
const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://')
@@ -66,11 +68,31 @@ export function getRelayIconOverrideSrc(url: string | undefined): string | undef
return undefined
}
+/** Loopback dev/cache relays (localhost, 127.0.0.1, ::1) — not broader LAN ranges. */
+export function isLoopbackRelayUrl(url: string | undefined): boolean {
+ const host = parseRelayHostname(url ?? '')
+ if (!host) return false
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1'
+}
+
+/**
+ * Lucide icon for relays that should not use NIP-11 / favicon (shown in {@link RelayIcon}).
+ * Takes precedence over {@link getRelayIconOverrideSrc} and NIP-11 `icon`.
+ */
+export function getRelayIconLucideFallback(url: string | undefined): RelayIconLucideFallback | undefined {
+ const host = parseRelayHostname(url ?? '')
+ if (!host) return undefined
+ if (host === 'search.nos.today') return 'search'
+ if (isLoopbackRelayUrl(url)) return 'home'
+ return undefined
+}
+
/**
* Unicode fallback when NIP-11 / favicon is missing or failed to load (shown in {@link RelayIcon}).
* Sovbit hosts use {@link getRelayIconOverrideSrc} favicons instead; purplepag uses the purple circle.
*/
export function getRelayIconFallbackGlyph(url: string | undefined): string | undefined {
+ if (getRelayIconLucideFallback(url)) return undefined
const host = parseRelayHostname(url ?? '')
if (!host) return undefined
if (host === 'purplepag.es') return '🟣'