SkateSpots/src/lib/nip05.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

87 lines
3 KiB
TypeScript

// 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 };
}