SkateSpots/src/hooks/useSkaterVerifications.ts
shakespeare.diy dbae3a1501 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>
2026-09-02 21:51:11 +07:00

69 lines
2.6 KiB
TypeScript

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;
},
});
}