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>
87 lines
3 KiB
TypeScript
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 };
|
|
}
|