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