From dbae3a1501c88dff2f2d523a75bae4e33b00a807 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 2 Sep 2026 21:51:11 +0700 Subject: [PATCH] Add soft NIP-05 verification for skaters (open directory + "Verified" filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the skater directory fully open — NIP-05 is a trust signal, not a gate: - Extract reusable NIP-05 verification into lib/nip05.ts (parse, fetch well-known, verify against expected pubkey); refactor useCrewNip05 to use it - New useSkaterVerifications hook: batch-fetches kind-0 metadata for all skaters and verifies each nip05 resolves to its own pubkey, returning a Set of verified pubkeys - SkatersPage: add a "Verified (N)" toggle (disabled when none verified) that filters to genuinely verified skaters; show ✓ badge only for truly-verified skaters; verified-aware empty state - NIP19Page profile: nip05 line now reflects real verification (lime ShieldCheck + "Verified" pill when the handle resolves to the key, muted grey otherwise) Co-authored-by: shakespeare.diy --- src/hooks/useCrewNip05.ts | 70 ++--------------------- src/hooks/useSkaterVerifications.ts | 69 +++++++++++++++++++++++ src/lib/nip05.ts | 87 +++++++++++++++++++++++++++++ src/pages/NIP19Page.tsx | 20 ++++++- src/pages/SkatersPage.tsx | 60 ++++++++++++++++---- 5 files changed, 228 insertions(+), 78 deletions(-) create mode 100644 src/hooks/useSkaterVerifications.ts create mode 100644 src/lib/nip05.ts diff --git a/src/hooks/useCrewNip05.ts b/src/hooks/useCrewNip05.ts index 9a0e4f9..e7bf7af 100644 --- a/src/hooks/useCrewNip05.ts +++ b/src/hooks/useCrewNip05.ts @@ -1,50 +1,7 @@ import { useQuery } from '@tanstack/react-query'; +import { verifyNip05, type Nip05Result } from '@/lib/nip05'; -export type Nip05Status = 'none' | 'verified' | 'mismatch' | 'unreachable'; - -interface Nip05Result { - status: Nip05Status; - /** The pubkey the domain claims for this name, if any. */ - resolvedPubkey?: string; -} - -/** Basic `local@domain.tld` shape check. */ -function parseNip05(nip05: string): { name: string; domain: string } | null { - const trimmed = nip05.trim().toLowerCase(); - const at = trimmed.indexOf('@'); - if (at <= 0) return null; - const name = trimmed.slice(0, at); - const domain = trimmed.slice(at + 1); - if (!/^[a-z0-9._-]+$/.test(name)) return null; - if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null; - return { name, domain }; -} - -async function fetchWellKnown(name: string, domain: string, signal: AbortSignal) { - const target = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`; - - // Try direct first; fall back to the CORS proxy (many nostr.json hosts don't - // send permissive CORS headers). - const attempts = [ - target, - `https://proxy.shakespeare.diy/?url=${encodeURIComponent(target)}`, - ]; - - for (const url of attempts) { - try { - const res = await fetch(url, { signal, headers: { Accept: 'application/json' } }); - if (!res.ok) continue; - const data = await res.json(); - const names = data?.names; - if (names && typeof names === 'object') { - return names as Record; - } - } catch { - // try next attempt - } - } - return null; -} +export type { Nip05Status } from '@/lib/nip05'; /** * Verify a crew's optional NIP-05 identifier against its founder pubkey. @@ -53,32 +10,13 @@ async function fetchWellKnown(name: string, domain: string, signal: AbortSignal) * the domain's `.well-known/nostr.json` maps the name to that same founder key. * This works with ANY domain the crew controls (fully decentralized) — SkateSpots * does not gatekeep the handle. - * - * States: - * - `verified`: domain maps the name to the founder pubkey. Trusted ✓ - * - `mismatch`: domain maps the name to a DIFFERENT pubkey. Do not trust. - * - `unreachable`: the domain / nostr.json couldn't be fetched (network/CORS). - * - `none`: no NIP-05 set, or malformed. */ export function useCrewNip05(nip05: string | undefined, founderPubkey: string | undefined) { return useQuery({ queryKey: ['crew-nip05', nip05, founderPubkey], - queryFn: async (c): Promise => { - if (!nip05 || !founderPubkey) return { status: 'none' }; - const parsed = parseNip05(nip05); - if (!parsed) return { status: 'none' }; - + queryFn: (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); - const names = await fetchWellKnown(parsed.name, parsed.domain, signal); - if (!names) return { status: 'unreachable' }; - - const resolved = names[parsed.name]?.toLowerCase(); - if (!resolved) return { status: 'unreachable' }; - - if (resolved === founderPubkey.toLowerCase()) { - return { status: 'verified', resolvedPubkey: resolved }; - } - return { status: 'mismatch', resolvedPubkey: resolved }; + return verifyNip05(nip05, founderPubkey, signal); }, enabled: !!nip05 && !!founderPubkey, staleTime: 10 * 60 * 1000, // 10 min diff --git a/src/hooks/useSkaterVerifications.ts b/src/hooks/useSkaterVerifications.ts new file mode 100644 index 0000000..c9bd9b9 --- /dev/null +++ b/src/hooks/useSkaterVerifications.ts @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { verifyNip05 } from '@/lib/nip05'; + +/** + * Batch-verify NIP-05 for a set of skater pubkeys. + * + * NIP-05 is a *soft* trust signal, never a gate — the whole directory stays open + * to everyone. This hook simply fetches each skater's kind-0 metadata, then + * confirms their `nip05` handle actually resolves to their own pubkey via the + * domain's `.well-known/nostr.json`. The result powers the "Verified only" + * filter and the verified badge, letting people *opt in* to a higher-trust view + * without excluding anyone. + * + * Returns a Set of pubkeys that are genuinely NIP-05 verified. + */ +export function useSkaterVerifications(pubkeys: string[]) { + const { nostr } = useNostr(); + + // Stable key so the query only refires when the actual set changes. + const sortedKey = [...pubkeys].sort().join(','); + + return useQuery({ + queryKey: ['skater-verifications', sortedKey], + enabled: pubkeys.length > 0, + staleTime: 10 * 60 * 1000, // 10 min — verification rarely changes + gcTime: 30 * 60 * 1000, + queryFn: async (c): Promise> => { + const verified = new Set(); + if (pubkeys.length === 0) return verified; + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]); + + // 1. Fetch kind-0 metadata for all skaters in one query. + const profiles = await nostr.query([{ kinds: [0], authors: pubkeys }], { signal }); + + // Map pubkey -> nip05 handle (newest metadata wins). + const nip05ByPubkey = new Map(); + const seenAt = new Map(); + for (const ev of profiles) { + const prev = seenAt.get(ev.pubkey) ?? -1; + if (ev.created_at <= prev) continue; + seenAt.set(ev.pubkey, ev.created_at); + try { + const meta = JSON.parse(ev.content) as { nip05?: string }; + if (meta.nip05) nip05ByPubkey.set(ev.pubkey, meta.nip05); + else nip05ByPubkey.delete(ev.pubkey); + } catch { + // ignore malformed metadata + } + } + + // 2. Verify each handle against its own pubkey, in parallel. + const entries = [...nip05ByPubkey.entries()]; + const results = await Promise.all( + entries.map(async ([pubkey, nip05]) => { + const res = await verifyNip05(nip05, pubkey, signal).catch(() => ({ status: 'unreachable' as const })); + return { pubkey, ok: res.status === 'verified' }; + }), + ); + + for (const { pubkey, ok } of results) { + if (ok) verified.add(pubkey); + } + + return verified; + }, + }); +} diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts new file mode 100644 index 0000000..bc8aad6 --- /dev/null +++ b/src/lib/nip05.ts @@ -0,0 +1,87 @@ +// Shared NIP-05 verification helpers. +// +// NIP-05 is a *soft* trust signal, not a gate: it proves the pubkey controls a +// `local@domain` handle whose `.well-known/nostr.json` maps that name back to +// the same pubkey. SkateSpots never gatekeeps the handle or the domain — any +// domain the user controls works, keeping verification fully decentralized. + +export type Nip05Status = 'none' | 'verified' | 'mismatch' | 'unreachable'; + +export interface Nip05Result { + status: Nip05Status; + /** The pubkey the domain claims for this name, if any. */ + resolvedPubkey?: string; +} + +/** Basic `local@domain.tld` shape check. */ +export function parseNip05(nip05: string): { name: string; domain: string } | null { + const trimmed = nip05.trim().toLowerCase(); + const at = trimmed.indexOf('@'); + if (at <= 0) return null; + const name = trimmed.slice(0, at); + const domain = trimmed.slice(at + 1); + if (!/^[a-z0-9._-]+$/.test(name)) return null; + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null; + return { name, domain }; +} + +/** + * Fetch a domain's `.well-known/nostr.json` name map. Tries the domain directly + * first, then falls back to the CORS proxy (many nostr.json hosts don't send + * permissive CORS headers). + */ +export async function fetchWellKnown( + name: string, + domain: string, + signal: AbortSignal, +): Promise | null> { + const target = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`; + const attempts = [ + target, + `https://proxy.shakespeare.diy/?url=${encodeURIComponent(target)}`, + ]; + + for (const url of attempts) { + try { + const res = await fetch(url, { signal, headers: { Accept: 'application/json' } }); + if (!res.ok) continue; + const data = await res.json(); + const names = data?.names; + if (names && typeof names === 'object') { + return names as Record; + } + } catch { + // try next attempt + } + } + return null; +} + +/** + * Verify a NIP-05 identifier against the pubkey that is supposed to own it. + * + * - `verified`: domain maps the name to `expectedPubkey`. Trusted ✓ + * - `mismatch`: domain maps the name to a DIFFERENT pubkey. Do not trust. + * - `unreachable`: the domain / nostr.json couldn't be fetched (network/CORS). + * - `none`: no NIP-05 set, or malformed. + */ +export async function verifyNip05( + nip05: string | undefined, + expectedPubkey: string | undefined, + signal: AbortSignal, +): Promise { + if (!nip05 || !expectedPubkey) return { status: 'none' }; + const parsed = parseNip05(nip05); + if (!parsed) return { status: 'none' }; + + const names = await fetchWellKnown(parsed.name, parsed.domain, signal); + if (!names) return { status: 'unreachable' }; + + const resolved = names[parsed.name]?.toLowerCase(); + if (!resolved) return { status: 'unreachable' }; + + if (resolved === expectedPubkey.toLowerCase()) { + return { status: 'verified', resolvedPubkey: resolved }; + } + return { status: 'mismatch', resolvedPubkey: resolved }; +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index cafb68c..f67abb3 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -15,6 +15,7 @@ import { useUserShops } from '@/hooks/useShops'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserPublicStash } from '@/hooks/useStashShowcase'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; @@ -30,6 +31,7 @@ import { ExternalLink, Copy, Check, + ShieldCheck, Users, Crown, Bot, @@ -67,6 +69,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) { const nip05 = metadata?.nip05; const lud16 = metadata?.lud16; const isBot = metadata?.bot === true; + // Soft NIP-05 verification — confirms the handle resolves to THIS pubkey. + const { data: nip05Result } = useCrewNip05(nip05, pubkey); + const nip05Verified = nip05Result?.status === 'verified'; // Custom CLINK offer code stored in the kind 0 content JSON. const raw = metadata as Record | undefined; const noffer = raw?.noffer; @@ -165,9 +170,20 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {nip05 && ( -

- +

+ {nip05Verified ? : } {nip05} + {nip05Verified && ( + + Verified + + )}

)} {/* Crew membership summary — links straight to the crews */} diff --git a/src/pages/SkatersPage.tsx b/src/pages/SkatersPage.tsx index c6b968d..15137c4 100644 --- a/src/pages/SkatersPage.tsx +++ b/src/pages/SkatersPage.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useSkaters, useUserSkaterProfile } from '@/hooks/useSkaters'; +import { useSkaterVerifications } from '@/hooks/useSkaterVerifications'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -23,12 +24,11 @@ import { cn } from '@/lib/utils'; import { PersonStanding, Search, MapPin, Check, Sparkles, Users, ShieldCheck, X } from 'lucide-react'; // ─── Skater card ──────────────────────────────────────────────────────────── -function SkaterCard({ skater }: { skater: Skater }) { +function SkaterCard({ skater, verified }: { skater: Skater; verified?: boolean }) { const author = useAuthor(skater.pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? skater.title ?? genUserName(skater.pubkey); const avatar = metadata?.picture ?? skater.image; - const nip05 = metadata?.nip05; const npub = nip19.npubEncode(skater.pubkey); const level = skater.level ? SKATER_LEVELS[skater.level] : undefined; @@ -66,7 +66,11 @@ function SkaterCard({ skater }: { skater: Skater }) {

{name}

- {nip05 && } + {verified && ( + + + + )}
{skater.location && (

@@ -138,6 +142,13 @@ export default function SkatersPage() { const [query, setQuery] = useState(''); const [styleFilter, setStyleFilter] = useState('all'); + const [verifiedOnly, setVerifiedOnly] = useState(false); + + // Soft NIP-05 verification for the whole list (never a gate — powers the + // "Verified only" toggle and the ✓ badge, so trust is opt-in, not enforced). + const skaterPubkeys = useMemo(() => (skaters ?? []).map((s) => s.pubkey), [skaters]); + const { data: verifiedSet, isLoading: verifying } = useSkaterVerifications(skaterPubkeys); + const verifiedCount = verifiedSet?.size ?? 0; // Which styles actually appear (to avoid empty filter chips). const availableStyles = useMemo(() => { @@ -148,6 +159,7 @@ export default function SkatersPage() { const filtered = useMemo(() => { let list = skaters ?? []; + if (verifiedOnly) list = list.filter((s) => verifiedSet?.has(s.pubkey)); if (styleFilter !== 'all') list = list.filter((s) => s.styles.includes(styleFilter)); if (query.trim()) { const q = query.trim().toLowerCase(); @@ -160,7 +172,7 @@ export default function SkatersPage() { ); } return list; - }, [skaters, styleFilter, query]); + }, [skaters, styleFilter, query, verifiedOnly, verifiedSet]); const total = skaters?.length ?? 0; @@ -234,8 +246,8 @@ export default function SkatersPage() { )}

- {/* Style chips */} - {availableStyles.length > 0 && ( + {/* Style chips + verified toggle */} + {(availableStyles.length > 0 || verifiedCount > 0) && (
+ + {/* Verified only — soft trust filter, not a gate */} + + {availableStyles.map((s) => (