Add soft NIP-05 verification for skaters (open directory + "Verified" filter)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 21:51:11 +07:00
parent bb6245e53c
commit dbae3a1501
5 changed files with 228 additions and 78 deletions

View file

@ -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<string, string>;
}
} 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<Nip05Result>({
queryKey: ['crew-nip05', nip05, founderPubkey],
queryFn: async (c): Promise<Nip05Result> => {
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

View file

@ -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<Set<string>> => {
const verified = new Set<string>();
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<string, string>();
const seenAt = new Map<string, number>();
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;
},
});
}

87
src/lib/nip05.ts Normal file
View file

@ -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<Record<string, string> | 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<string, string>;
}
} 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<Nip05Result> {
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 };
}

View file

@ -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<string, unknown> | undefined;
const noffer = raw?.noffer;
@ -165,9 +170,20 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<UserStatusIndicator pubkey={pubkey} size="md" showLabel className="hidden sm:flex" />
</div>
{nip05 && (
<p className="text-sm text-[#ccff00] font-medium flex items-center justify-center sm:justify-start gap-1">
<Check className="h-3 w-3" />
<p
className={cn(
'text-sm font-medium flex items-center justify-center sm:justify-start gap-1',
nip05Verified ? 'text-[#ccff00]' : 'text-gray-400',
)}
title={nip05Verified ? 'NIP-05 verified' : 'NIP-05 not verified against this key'}
>
{nip05Verified ? <ShieldCheck className="h-3.5 w-3.5" /> : <Check className="h-3 w-3" />}
{nip05}
{nip05Verified && (
<span className="text-[10px] font-black uppercase tracking-wide bg-[#ccff00]/15 text-[#ccff00] px-1.5 py-0.5 rounded-full ml-0.5">
Verified
</span>
)}
</p>
)}
{/* Crew membership summary — links straight to the crews */}

View file

@ -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 }) {
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-1.5">
<h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3>
{nip05 && <ShieldCheck className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />}
{verified && (
<span title="NIP-05 verified" className="flex-shrink-0">
<ShieldCheck className="h-3.5 w-3.5 text-[#ccff00]" />
</span>
)}
</div>
{skater.location && (
<p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5 truncate">
@ -138,6 +142,13 @@ export default function SkatersPage() {
const [query, setQuery] = useState('');
const [styleFilter, setStyleFilter] = useState<SkaterStyle | 'all'>('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() {
)}
</div>
{/* Style chips */}
{availableStyles.length > 0 && (
{/* Style chips + verified toggle */}
{(availableStyles.length > 0 || verifiedCount > 0) && (
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none pb-0.5">
<button
onClick={() => setStyleFilter('all')}
@ -246,6 +258,28 @@ export default function SkatersPage() {
>
All
</button>
{/* Verified only — soft trust filter, not a gate */}
<button
onClick={() => setVerifiedOnly((v) => !v)}
disabled={verifiedCount === 0 && !verifiedOnly}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all whitespace-nowrap flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed',
verifiedOnly
? 'bg-[#ccff00] border-[#ccff00] text-black'
: 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ccff00]/40',
)}
title="Show only skaters with a verified NIP-05 identity"
>
<ShieldCheck className="h-3.5 w-3.5" />
Verified
{verifying ? (
<span className="opacity-60">…</span>
) : (
verifiedCount > 0 && <span className="opacity-70">({verifiedCount})</span>
)}
</button>
{availableStyles.map((s) => (
<button
key={s}
@ -292,10 +326,16 @@ export default function SkatersPage() {
</>
) : (
<>
<p className="text-lg font-bold text-white mb-1">No matches</p>
<p className="text-gray-400 text-sm mb-4">Try a different search or clear your filters.</p>
<p className="text-lg font-bold text-white mb-1">
{verifiedOnly ? 'No verified skaters match' : 'No matches'}
</p>
<p className="text-gray-400 text-sm mb-4">
{verifiedOnly
? 'Try turning off the “Verified” filter to see everyone.'
: 'Try a different search or clear your filters.'}
</p>
<button
onClick={() => { setQuery(''); setStyleFilter('all'); }}
onClick={() => { setQuery(''); setStyleFilter('all'); setVerifiedOnly(false); }}
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
>
Clear filters
@ -306,7 +346,7 @@ export default function SkatersPage() {
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{shown.map((s) => <SkaterCard key={s.pubkey} skater={s} />)}
{shown.map((s) => <SkaterCard key={s.pubkey} skater={s} verified={verifiedSet?.has(s.pubkey)} />)}
</div>
);
})()}