nip05-validator.ts raw
1 /**
2 * NIP-05 Verification Helper
3 *
4 * Directly validates NIP-05 identifiers by fetching the .well-known/nostr.json
5 * file and comparing the pubkey.
6 */
7
8 export interface Nip05ValidationResult {
9 valid: boolean;
10 pubkey?: string;
11 relays?: string[];
12 error?: string;
13 }
14
15 /**
16 * Parse a NIP-05 identifier into its components
17 * @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev" or "_@mleku.dev")
18 * @returns Object with name and domain, or null if invalid
19 */
20 export function parseNip05(nip05: string): { name: string; domain: string } | null {
21 if (!nip05 || typeof nip05 !== 'string') {
22 return null;
23 }
24
25 const parts = nip05.toLowerCase().trim().split('@');
26 if (parts.length !== 2) {
27 return null;
28 }
29
30 const [name, domain] = parts;
31 if (!name || !domain) {
32 return null;
33 }
34
35 // Basic domain validation
36 if (!domain.includes('.') || domain.includes('/')) {
37 return null;
38 }
39
40 return { name, domain };
41 }
42
43 /**
44 * Validate a NIP-05 identifier against a pubkey
45 *
46 * @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev")
47 * @param expectedPubkey - The expected pubkey in hex format
48 * @param timeoutMs - Fetch timeout in milliseconds
49 * @returns Validation result with status and any discovered relays
50 */
51 export async function validateNip05(
52 nip05: string,
53 expectedPubkey: string,
54 timeoutMs = 10000
55 ): Promise<Nip05ValidationResult> {
56 const parsed = parseNip05(nip05);
57 if (!parsed) {
58 return { valid: false, error: 'Invalid NIP-05 format' };
59 }
60
61 const { name, domain } = parsed;
62 const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
63
64 try {
65 const controller = new AbortController();
66 const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
67
68 const response = await fetch(url, {
69 signal: controller.signal,
70 headers: {
71 'Accept': 'application/json',
72 },
73 });
74
75 clearTimeout(timeoutId);
76
77 if (!response.ok) {
78 return {
79 valid: false,
80 error: `HTTP ${response.status}: ${response.statusText}`,
81 };
82 }
83
84 const data = await response.json();
85
86 // Check if the names object exists and contains the requested name
87 if (!data.names || typeof data.names !== 'object') {
88 return { valid: false, error: 'Invalid nostr.json structure: missing names' };
89 }
90
91 // NIP-05 names are case-insensitive
92 const pubkeyFromJson = data.names[name] || data.names[name.toLowerCase()];
93
94 if (!pubkeyFromJson) {
95 return { valid: false, error: `Name "${name}" not found in nostr.json` };
96 }
97
98 // Compare pubkeys (case-insensitive hex comparison)
99 const normalizedExpected = expectedPubkey.toLowerCase();
100 const normalizedFound = pubkeyFromJson.toLowerCase();
101 const valid = normalizedExpected === normalizedFound;
102
103 // Extract relays if present
104 let relays: string[] | undefined;
105 if (data.relays && typeof data.relays === 'object') {
106 const relayList = data.relays[pubkeyFromJson] || data.relays[normalizedFound];
107 if (Array.isArray(relayList)) {
108 relays = relayList;
109 }
110 }
111
112 return {
113 valid,
114 pubkey: pubkeyFromJson,
115 relays,
116 error: valid ? undefined : 'Pubkey mismatch',
117 };
118 } catch (error) {
119 if (error instanceof Error) {
120 if (error.name === 'AbortError') {
121 return { valid: false, error: 'Request timeout' };
122 }
123 return { valid: false, error: error.message };
124 }
125 return { valid: false, error: 'Unknown error' };
126 }
127 }
128