Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
d65dcf671c
  1. 25
      src/components/Nip05List/index.tsx
  2. 5
      src/i18n/locales/cs.ts
  3. 7
      src/i18n/locales/de.ts
  4. 5
      src/i18n/locales/en.ts
  5. 5
      src/i18n/locales/es.ts
  6. 5
      src/i18n/locales/fr.ts
  7. 5
      src/i18n/locales/nl.ts
  8. 5
      src/i18n/locales/pl.ts
  9. 5
      src/i18n/locales/ru.ts
  10. 5
      src/i18n/locales/tr.ts
  11. 5
      src/i18n/locales/zh.ts
  12. 205
      src/lib/nip05.ts
  13. 62
      src/lib/profile-relay-search-filters.ts
  14. 195
      src/providers/NostrProvider/index.tsx
  15. 3
      src/providers/NostrProvider/nip-07.signer.ts
  16. 4
      src/services/client-query.service.ts
  17. 77
      src/services/client.service.ts

25
src/components/Nip05List/index.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton'
import { verifyNip05 } from '@/lib/nip05'
import { splitNip05Identifier, verifyNip05 } from '@/lib/nip05'
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { BadgeAlert, BadgeCheck } from 'lucide-react'
@ -24,13 +24,13 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; @@ -24,13 +24,13 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
const newVerifications = new Map<string, Nip05Verification>()
// Initialize all as fetching
nip05List.forEach(nip05 => {
const [nip05Name, nip05Domain] = nip05.split('@')
nip05List.forEach((nip05) => {
const parts = splitNip05Identifier(nip05.trim())
newVerifications.set(nip05, {
nip05,
isVerified: false,
nip05Name: nip05Name || '',
nip05Domain: nip05Domain || '',
nip05Name: parts?.name ?? '',
nip05Domain: parts?.domain ?? '',
isFetching: true
})
})
@ -43,11 +43,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; @@ -43,11 +43,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
const result = await verifyNip05(nip05, pubkey)
setVerifications(prev => {
const updated = new Map(prev)
const fb = splitNip05Identifier(nip05.trim())
updated.set(nip05, {
nip05,
isVerified: result.isVerified,
nip05Name: result.nip05Name || nip05.split('@')[0] || '',
nip05Domain: result.nip05Domain || nip05.split('@')[1] || '',
nip05Name: result.nip05Name || fb?.name || '',
nip05Domain: result.nip05Domain || fb?.domain || '',
isFetching: false
})
return updated
@ -55,11 +56,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; @@ -55,11 +56,12 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
} catch (error) {
setVerifications(prev => {
const updated = new Map(prev)
const fb = splitNip05Identifier(nip05.trim())
const existing = updated.get(nip05) || {
nip05,
isVerified: false,
nip05Name: nip05.split('@')[0] || '',
nip05Domain: nip05.split('@')[1] || '',
nip05Name: fb?.name || '',
nip05Domain: fb?.domain || '',
isFetching: false
}
updated.set(nip05, { ...existing, isFetching: false })
@ -81,8 +83,9 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[]; @@ -81,8 +83,9 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
const verification = verifications.get(nip05)
const isFetching = verification?.isFetching ?? true
const isVerified = verification?.isVerified ?? false
const nip05Name = verification?.nip05Name || nip05.split('@')[0] || ''
const nip05Domain = verification?.nip05Domain || nip05.split('@')[1] || ''
const fb = splitNip05Identifier(nip05.trim())
const nip05Name = verification?.nip05Name || fb?.name || ''
const nip05Domain = verification?.nip05Domain || fb?.domain || ''
if (isFetching) {
return (

5
src/i18n/locales/cs.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

7
src/i18n/locales/de.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Zum Ausführen anmelden (verwendet $me oder $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.reloadPage": "Reload page",
"nip07.extensionKeyMismatch": "Ihre Browser-Erweiterung verwendet auf diesem Tab einen anderen Schlüssel. Wechseln Sie in der Erweiterung zum passenden Schlüssel, laden Sie die Seite neu, um die aktuelle Erweiterungsauswahl zu übernehmen, oder nutzen Sie die andere Aktion in dieser Meldung, um sich mit dem in der Erweiterung gewählten Schlüssel anzumelden.",
"nip07.reloadPage": "Seite neu laden",
"nip07.useExtensionIdentity": "Erweiterungs-Identität verwenden",
"nip07.switchedToExtensionIdentity": "Auf die aktuelle Identität Ihrer Erweiterung umgestellt.",
"nip07.adoptExtensionFailed": "Wechsel zur Erweiterungs-Identität fehlgeschlagen",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/en.ts

@ -1574,8 +1574,11 @@ export default { @@ -1574,8 +1574,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/es.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/fr.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/nl.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/pl.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/ru.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/tr.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

5
src/i18n/locales/zh.ts

@ -1539,8 +1539,11 @@ export default { @@ -1539,8 +1539,11 @@ export default {
"Location (optional)": "Location (optional)",
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, or reload the page to apply your extension's current selection.",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",
"nip07.adoptExtensionFailed": "Could not switch to extension identity",
"Login to configure RSS feeds": "Login to configure RSS feeds",
"Long-form Article": "Long-form Article",
"Mailbox relays saved": "Mailbox relays saved",

205
src/lib/nip05.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { LRUCache } from 'lru-cache'
import { nip19 } from 'nostr-tools'
import { buildViteProxySitesFetchUrl } from '@/lib/vite-proxy-url'
import { isValidPubkey } from './pubkey'
import { hexPubkeysEqual, isValidPubkey, normalizeHexPubkey } from './pubkey'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
@ -11,6 +12,9 @@ type TVerifyNip05Result = { @@ -11,6 +12,9 @@ type TVerifyNip05Result = {
relays?: string[]
}
/** Bumps when verification rules change so LRU does not serve stale false negatives. */
const VERIFY_CACHE_SCHEMA = 4
function asNip05LookupString(value: unknown): string {
if (typeof value === 'string') return value
if (value == null) return ''
@ -23,43 +27,146 @@ function asNip05LookupString(value: unknown): string { @@ -23,43 +27,146 @@ function asNip05LookupString(value: unknown): string {
return String(value)
}
/**
* Split `local@domain` on the **first** `@` only (NIP-05 local part must not contain `@`).
* `split('@')` breaks for domains like `user@sub.example.com` wrong domain.
*/
export function splitNip05Identifier(nip05Str: string): { name: string; domain: string } | null {
const s = nip05Str.trim()
const at = s.indexOf('@')
if (at <= 0 || at >= s.length - 1) return null
const name = s.slice(0, at).trim()
const domain = s.slice(at + 1).trim().replace(/\.$/, '')
if (!name || !domain) return null
return { name, domain: domain.toLowerCase() }
}
/** Normalize a `names` entry: hex (any case) or `npub1…` → lowercase hex, else null. */
function pubkeyHexFromWellKnownNamesValue(v: unknown): string | null {
if (typeof v !== 'string') return null
let t = v.trim()
if (t.startsWith('0x') || t.startsWith('0X')) t = t.slice(2).trim()
if (/^[0-9a-f]{64}$/i.test(t)) return t.toLowerCase()
if (t.startsWith('npub1')) {
try {
const { type, data } = nip19.decode(t)
if (type === 'npub' && typeof data === 'string' && isValidPubkey(data)) return data.toLowerCase()
} catch {
return null
}
}
return null
}
function getNamesEntryRaw(names: Record<string, unknown>, nip05Name: string): string | undefined {
if (!nip05Name || typeof names !== 'object' || names == null) return undefined
const asTrimmedString = (x: unknown): string | undefined =>
typeof x === 'string' && x.trim() ? x.trim() : undefined
const direct = asTrimmedString(names[nip05Name])
if (direct !== undefined) return direct
const want = nip05Name.toLowerCase()
for (const k of Object.keys(names)) {
if (k.toLowerCase() === want) return asTrimmedString(names[k])
}
return undefined
}
function pickRelayListForPubkey(
relays: Record<string, unknown> | undefined,
userPubkeyHex: string,
listedRaw: string,
nip05LocalName: string
): unknown {
if (!relays || typeof relays !== 'object') return undefined
const user = normalizeHexPubkey(userPubkeyHex)
const keysToTry = new Set<string>()
keysToTry.add(user)
keysToTry.add(userPubkeyHex.trim())
keysToTry.add(listedRaw.trim())
const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw)
if (listedHex) {
keysToTry.add(listedHex)
try {
keysToTry.add(nip19.npubEncode(listedHex))
} catch {
/* ignore */
}
}
for (const k of keysToTry) {
if (!k) continue
const v = relays[k]
if (Array.isArray(v)) return v
}
for (const k of Object.keys(relays)) {
if (pubkeyHexFromWellKnownNamesValue(k) === user) {
const v = relays[k]
if (Array.isArray(v)) return v
}
}
/** Some hosts key `relays` by NIP-05 local part (non-standard); NIP recommends pubkey keys. */
const local = nip05LocalName.trim()
if (local) {
const want = local.toLowerCase()
for (const k of Object.keys(relays)) {
if (k.toLowerCase() === want) {
const v = relays[k]
if (Array.isArray(v)) return v
}
}
}
return undefined
}
const verifyNip05ResultCache = new LRUCache<string, TVerifyNip05Result>({
max: 1000,
fetchMethod: (key) => {
const { nip05, pubkey } = JSON.parse(key) as { nip05?: unknown; pubkey?: unknown }
return _verifyNip05(asNip05LookupString(nip05), typeof pubkey === 'string' ? pubkey : '')
const parsed = JSON.parse(key) as { s?: number; nip05?: unknown; pubkey?: unknown }
const nip05 = asNip05LookupString(parsed.nip05).trim()
const pubkey = typeof parsed.pubkey === 'string' ? parsed.pubkey.trim() : ''
return _verifyNip05(nip05, pubkey)
}
})
async function _verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const nip05Str = asNip05LookupString(nip05)
const parts = nip05Str ? nip05Str.split('@') : []
const nip05Name = parts[0]
const nip05Domain = parts[1]
const nip05Str = asNip05LookupString(nip05).trim()
const split = splitNip05Identifier(nip05Str)
const nip05Name = split?.name ?? ''
const nip05Domain = split?.domain ?? ''
const result: TVerifyNip05Result = { isVerified: false, nip05Name, nip05Domain }
if (!nip05Name || !nip05Domain || !pubkey) return result
if (!split || !pubkey || !isValidPubkey(pubkey)) return result
const userHex = normalizeHexPubkey(pubkey)
const json = await fetchWellKnownNostrJson(nip05Domain, nip05Name)
if (json) {
const names = json.names as Record<string, string> | undefined
if (names?.[nip05Name] === pubkey) {
if (!json) return result
const names = json.names as Record<string, unknown> | undefined
if (!names || typeof names !== 'object') return result
const listedRaw = getNamesEntryRaw(names, nip05Name)
if (listedRaw == null) return result
const listedHex = pubkeyHexFromWellKnownNamesValue(listedRaw)
if (!listedHex || !hexPubkeysEqual(listedHex, userHex)) return result
const relays = json.relays as Record<string, unknown> | undefined
const relayList = relays?.[pubkey]
return { ...result, isVerified: true, relays: Array.isArray(relayList) ? relayList : undefined }
}
}
return result
const relayList = pickRelayListForPubkey(relays, userHex, listedRaw, nip05Name)
return { ...result, isVerified: true, relays: Array.isArray(relayList) ? (relayList as string[]) : undefined }
}
export async function verifyNip05(nip05: string, pubkey: string): Promise<TVerifyNip05Result> {
const nip05Str = asNip05LookupString(nip05)
const pubkeyStr = typeof pubkey === 'string' ? pubkey : ''
const result = await verifyNip05ResultCache.fetch(JSON.stringify({ nip05: nip05Str, pubkey: pubkeyStr }))
if (result) {
return result
}
const parts = nip05Str ? nip05Str.split('@') : []
return { isVerified: false, nip05Name: parts[0], nip05Domain: parts[1] }
const nip05Str = asNip05LookupString(nip05).trim()
const pubkeyStr = typeof pubkey === 'string' ? pubkey.trim() : ''
const pubkeyNorm = isValidPubkey(pubkeyStr) ? normalizeHexPubkey(pubkeyStr) : pubkeyStr
const cached = await verifyNip05ResultCache.fetch(
JSON.stringify({ s: VERIFY_CACHE_SCHEMA, nip05: nip05Str, pubkey: pubkeyNorm })
)
if (cached) return cached
const split = splitNip05Identifier(nip05Str)
return {
isVerified: false,
nip05Name: split?.name ?? '',
nip05Domain: split?.domain ?? ''
}
}
export function getWellKnownNip05Url(domain: string, name?: string): string {
@ -74,17 +181,23 @@ export function getWellKnownNip05Url(domain: string, name?: string): string { @@ -74,17 +181,23 @@ export function getWellKnownNip05Url(domain: string, name?: string): string {
* Fetch `/.well-known/nostr.json` in the browser without tripping third-party CORS:
* when `VITE_PROXY_SERVER` is set (production), use same-origin `/sites/?url=…` like OG preview.
*/
async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, name)
async function fetchWellKnownNostrJsonOnce(
domain: string,
nameInQuery: string | undefined
): Promise<Record<string, unknown> | null> {
const targetUrl = getWellKnownNip05Url(domain, nameInQuery)
const proxyServer = import.meta.env.VITE_PROXY_SERVER?.trim()
const fetchUrl = proxyServer ? buildViteProxySitesFetchUrl(targetUrl, proxyServer) : targetUrl
try {
const res = await fetchWithTimeout(fetchUrl, {
credentials: 'omit',
headers: { Accept: 'application/json, text/plain;q=0.9,*/*;q=0.8' },
headers: {
Accept: 'application/nostr+json, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1'
},
timeoutMs: 15_000
})
if (!res.ok) return null
/** NIP-05: well-known MUST NOT redirect; following redirects can land on unrelated JSON. */
if (res.redirected || !res.ok) return null
const data: unknown = await res.json()
return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : null
} catch {
@ -92,21 +205,39 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<R @@ -92,21 +205,39 @@ async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<R
}
}
/** Fetch `/.well-known/nostr.json` (with optional `?name=`). Retries without `name` if the entry is missing. */
async function fetchWellKnownNostrJson(domain: string, name?: string): Promise<Record<string, unknown> | null> {
const trimmedName = typeof name === 'string' ? name.trim() : ''
const withQuery =
trimmedName.length > 0 ? await fetchWellKnownNostrJsonOnce(domain, trimmedName) : null
if (withQuery && typeof withQuery.names === 'object' && withQuery.names != null) {
if (getNamesEntryRaw(withQuery.names as Record<string, unknown>, trimmedName) != null) {
return withQuery
}
}
const full = await fetchWellKnownNostrJsonOnce(domain, undefined)
if (full && trimmedName && typeof full.names === 'object' && full.names != null) {
if (getNamesEntryRaw(full.names as Record<string, unknown>, trimmedName) != null) {
return full
}
}
return withQuery ?? full
}
export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> {
try {
const json = await fetchWellKnownNostrJson(domain)
if (!json) return []
const pubkeySet = new Set<string>()
return Object.values((json.names as Record<string, string>) || {}).filter((pubkey) => {
if (typeof pubkey !== 'string' || !isValidPubkey(pubkey)) {
return false
}
if (pubkeySet.has(pubkey)) {
return false
const out: string[] = []
for (const v of Object.values((json.names as Record<string, unknown>) || {})) {
const hex = pubkeyHexFromWellKnownNamesValue(v)
if (!hex || !isValidPubkey(hex)) continue
if (pubkeySet.has(hex)) continue
pubkeySet.add(hex)
out.push(hex)
}
pubkeySet.add(pubkey)
return true
}) as string[]
return out
} catch (error) {
logger.error('Error fetching pubkeys from domain', { error, domain })
return []

62
src/lib/profile-relay-search-filters.ts

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
import type { Filter } from 'nostr-tools'
import { kinds } from 'nostr-tools'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
/**
* REQ filters for kind 0 profile discovery: NIP-50 `search` plus tag-shaped queries
* (`#nip05`, `#name`, `#display_name`) that profile mirrors often index.
* When the query is a hex pubkey, `npub`, or `nprofile`, relays are queried by `authors`
* (NIP-50 text search does not match raw hex or bech32).
* Multiple filters are ORmerged by relays.
*/
export function buildProfileKind0SearchFilters(opts: {
search: string
limit: number
until?: number
}): Filter[] {
const search = opts.search.trim()
if (!search) return []
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500))
const time =
typeof opts.until === 'number' && opts.until > 0 ? ({ until: opts.until } as Pick<Filter, 'until'>) : {}
const k = [kinds.Metadata] as number[]
const pubkeyHex = decodeProfileSearchQueryToPubkeyHex(search)
if (pubkeyHex) {
return [{ kinds: k, authors: [pubkeyHex], limit, ...time }]
}
const seen = new Set<string>()
const out: Filter[] = []
const add = (f: Filter) => {
const key = JSON.stringify(f)
if (seen.has(key)) return
seen.add(key)
out.push(f)
}
add({ kinds: k, search, limit, ...time })
if (search.includes('@')) {
const firstToken = search.split(/\s+/)[0] ?? search
const nipLower = firstToken.trim().toLowerCase()
if (nipLower) add({ kinds: k, '#nip05': [nipLower], limit, ...time })
const nipExact = firstToken.trim()
if (nipExact && nipExact !== nipLower) add({ kinds: k, '#nip05': [nipExact], limit, ...time })
}
const token = search.startsWith('@') ? search.slice(1).trim() : search.trim()
if (
token &&
!/\s/.test(token) &&
token.length <= 80 &&
/^[a-zA-Z0-9._-]+$/.test(token) &&
!token.includes('@')
) {
add({ kinds: k, '#name': [token], limit, ...time })
add({ kinds: k, '#display_name': [token], limit, ...time })
}
return out
}

195
src/providers/NostrProvider/index.tsx

@ -1005,81 +1005,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1005,81 +1005,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
void customEmojiService.init(userEmojiListEvent, account.pubkey, profileEvent)
}, [userEmojiListEvent, account?.pubkey, profileEvent])
/**
* If session restore temporarily fell back to read-only (`npub`) while the stored
* account is still `nip-07`, periodically retry reconnecting the extension signer.
*/
useEffect(() => {
if (!account || account.signerType !== 'npub') return
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return
if (preferred.pubkey !== account.pubkey) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
const maxAttempts = 10
const schedule = (ms: number) => {
if (cancelled) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
void tryRecover()
}, ms)
}
const tryRecover = async () => {
if (cancelled || attempts >= maxAttempts) return
attempts += 1
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: pubkey.slice(0, 12),
attempts
})
return
} catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) {
logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', {
attempts,
wantedPubkey: preferred.pubkey.slice(0, 12)
})
if (!nip07KeyMismatchToastShownRef.current) {
nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), {
duration: 20_000,
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }
})
}
// Keep retrying — the extension may update its approved key after a moment.
schedule(3_000)
return
}
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
attempts,
error: error instanceof Error ? error.message : String(error)
})
}
schedule(Math.min(10_000, attempts * 1_500))
}
schedule(1_200)
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
// nip07RecoveryBump is incremented by switchAccount after it updates storage following an
// npub fallback, so the loop re-fires with the correct preferred account.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, nip07RecoveryBump])
const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
}
@ -1350,11 +1275,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1350,11 +1275,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
(isNip07SignerPubkeyMismatchError(err) || isNip07SignerPubkeyMismatchError(lastNip07Err)) &&
!nip07KeyMismatchToastShownRef.current
) {
nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), {
duration: 20_000,
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }
})
fireNip07ExtensionKeyMismatchToast()
}
return fallbackToReadOnlyNpub(storedAccount.pubkey, err)
}
@ -1391,6 +1312,120 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -1391,6 +1312,120 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return null
}
/**
* Stored NIP-07 account pubkey no longer matches the extension (user switched keys).
* Drop the stale stored NIP-07 row and sign in with whatever pubkey the extension returns now.
*/
const adoptCurrentExtensionNip07Identity = useEventCallback(async () => {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const extPubkey = await nip07Signer.getPublicKey()
if (!extPubkey?.trim()) {
throw new Error('Empty pubkey from extension')
}
const preferred = storage.getCurrentAccount()
if (
preferred?.signerType === 'nip-07' &&
preferred.pubkey.toLowerCase() !== extPubkey.toLowerCase()
) {
removeAccount(preferred)
}
const existing = storage
.getAccounts()
.find((a) => a.pubkey.toLowerCase() === extPubkey.toLowerCase() && a.signerType === 'nip-07')
const act: TAccount = existing ?? { pubkey: extPubkey, signerType: 'nip-07' }
login(nip07Signer, act)
toast.success(t('nip07.switchedToExtensionIdentity'))
} catch (e) {
toast.error(`${t('nip07.adoptExtensionFailed')}: ${e instanceof Error ? e.message : String(e)}`)
}
})
const fireNip07ExtensionKeyMismatchToast = useCallback(() => {
if (nip07KeyMismatchToastShownRef.current) return
nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), {
duration: 35_000,
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() },
cancel: {
label: t('nip07.useExtensionIdentity'),
onClick: () => {
void adoptCurrentExtensionNip07Identity()
}
}
})
}, [t, adoptCurrentExtensionNip07Identity])
/**
* If session restore temporarily fell back to read-only (`npub`) while the stored
* account is still `nip-07`, periodically retry reconnecting the extension signer.
*/
useEffect(() => {
if (!account || account.signerType !== 'npub') return
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return
if (preferred.pubkey !== account.pubkey) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
const maxAttempts = 10
const schedule = (ms: number) => {
if (cancelled) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
void tryRecover()
}, ms)
}
const tryRecover = async () => {
if (cancelled || attempts >= maxAttempts) return
attempts += 1
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error(NIP07_SIGNER_PUBKEY_MISMATCH_MSG)
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: pubkey.slice(0, 12),
attempts
})
return
} catch (error) {
if (isNip07SignerPubkeyMismatchError(error)) {
logger.info('[NostrProvider] NIP-07 recovery: extension key mismatch on attempt', {
attempts,
wantedPubkey: preferred.pubkey.slice(0, 12)
})
fireNip07ExtensionKeyMismatchToast()
// Keep retrying — the extension may update its approved key after a moment.
schedule(3_000)
return
}
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
attempts,
error: error instanceof Error ? error.message : String(error)
})
}
schedule(Math.min(10_000, attempts * 1_500))
}
schedule(1_200)
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
// nip07RecoveryBump is incremented by switchAccount after it updates storage following an
// npub fallback, so the loop re-fires with the correct preferred account.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account, nip07RecoveryBump, fireNip07ExtensionKeyMismatchToast])
const normalizeDraftEventTags = (
draftEvent: TDraftEvent,
options?: { addClientTag?: boolean }

3
src/providers/NostrProvider/nip-07.signer.ts

@ -27,9 +27,8 @@ export class Nip07Signer implements ISigner { @@ -27,9 +27,8 @@ export class Nip07Signer implements ISigner {
if (!this.signer) {
throw new Error('Should call init() first')
}
if (!this.pubkey) {
/** Always ask the extension — it may change the active key without a full page reload. */
this.pubkey = await this.signer.getPublicKey()
}
return this.pubkey
}

4
src/services/client-query.service.ts

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
SOCIAL_KIND_BLOCKED_RELAY_URLS,
MAX_CONCURRENT_RELAY_CONNECTIONS,
MAX_CONCURRENT_SUBS_PER_RELAY,
PROFILE_RELAY_URLS,
RELAY_FILTER_MAX_KINDS_PER_OBJECT,
RELAY_REQ_MAX_FILTERS_PER_MESSAGE,
RELAY_POOL_CONNECTION_TIMEOUT_MS,
@ -623,7 +624,8 @@ export class QueryService { @@ -623,7 +624,8 @@ export class QueryService {
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
])
const groupedRequests = Array.from(grouped.entries()).map(([url, f]) => {

77
src/services/client.service.ts

@ -29,6 +29,7 @@ import { @@ -29,6 +29,7 @@ import {
DEFAULT_FAVORITE_RELAYS,
NIP66_DISCOVERY_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS,
PROFILE_RELAY_URLS,
READ_ONLY_RELAY_URLS,
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS,
SEARCHABLE_RELAY_URLS
@ -174,6 +175,7 @@ import indexedDb from './indexed-db.service' @@ -174,6 +175,7 @@ import indexedDb from './indexed-db.service'
import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge'
import nip66Service from './nip66.service'
import { buildProfileKind0SearchFilters } from '@/lib/profile-relay-search-filters'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-failure'
import {
compactFilterForRelayLog,
@ -3357,36 +3359,75 @@ class ClientService extends EventTarget { @@ -3357,36 +3359,75 @@ class ClientService extends EventTarget {
const normalizedAll = dedupeNormalizeRelayUrlsOrdered(
relayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
let urls = normalizedAll
if (searchStr.length > 0) {
const profileRelayLayer = dedupeNormalizeRelayUrlsOrdered(
PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
)
const searchableSet = new Set([
...SEARCHABLE_RELAY_URLS.map((u) => normalizeUrl(u) || u),
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u)
...nip66Service.getSearchableRelayUrls().map((u) => normalizeUrl(u) || u),
...PROFILE_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean)
])
let urls = normalizedAll
if (searchStr.length > 0) {
const searchCapable = normalizedAll.filter(
(u) => searchableSet.has(u) || nip66Service.isRelaySearchable(u)
)
if (searchCapable.length > 0) {
urls = searchCapable
urls = dedupeNormalizeRelayUrlsOrdered([...searchCapable, ...profileRelayLayer])
if (urls.length === 0) {
urls = normalizedAll
}
}
const events = await this.queryService.query(
urls,
{
...filter,
kinds: [kinds.Metadata]
},
undefined,
{
replaceableRace: true,
// Search spans many relays; sub-second EOSE was cutting off almost all index relays.
const limitCap = Math.max(1, Math.min(filter.limit ?? 100, 500))
const queryFilter: Filter | Filter[] =
searchStr.length > 0
? (() => {
const built = buildProfileKind0SearchFilters({
search: searchStr,
limit: limitCap,
until: filter.until
})
return built.length > 0 ? built : [{ ...filter, kinds: [kinds.Metadata] }]
})()
: { ...filter, kinds: [kinds.Metadata] }
const events = await this.queryService.query(urls, queryFilter, undefined, {
replaceableRace: false,
eoseTimeout: 4500,
globalTimeout: 9000
globalTimeout: 9000,
relayOpSource: 'ClientService.searchProfiles'
})
/** Which relays actually delivered each kind-0 id (for tuning SEARCHABLE_RELAY_URLS). DEBUG only. */
if (searchStr.length > 0) {
const relayHitCounts = new Map<string, number>()
for (const e of events) {
if (e.kind !== kinds.Metadata) continue
for (const u of this.queryService.getSeenEventRelayUrls(e.id)) {
const n = (normalizeUrl(u) || u).trim()
if (!n) continue
relayHitCounts.set(n, (relayHitCounts.get(n) ?? 0) + 1)
}
}
if (relayHitCounts.size > 0) {
const relayHits = [...relayHitCounts.entries()].sort((a, b) => b[1] - a[1])
logger.debug('[ClientService.searchProfiles] kind=0 deliveries by relay URL (count = events relay sent for this query)', {
searchPreview: searchStr.slice(0, 80),
totalKind0Events: events.filter((e) => e.kind === kinds.Metadata).length,
relayHits: Object.fromEntries(relayHits)
})
}
}
)
const profileEvents = events.sort((a, b) => b.created_at - a.created_at)
const byPk = new Map<string, NEvent>()
for (const e of events) {
if (e.kind !== kinds.Metadata) continue
const prev = byPk.get(e.pubkey)
if (!prev || e.created_at > prev.created_at) {
byPk.set(e.pubkey, e)
}
}
const profileEvents = [...byPk.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limitCap)
await Promise.allSettled(profileEvents.map((profile) => this.addUsernameToIndex(profile)))
profileEvents.forEach((profile) => this.updateProfileEventCache(profile))
return profileEvents.map((profileEvent) => getProfileFromEvent(profileEvent))

Loading…
Cancel
Save