Skip to content

WHOIS & RDAP Lookup — TypeScript source

Look up registration data for any domain, IP address, or ASN — straight from your browser to the authoritative RDAP registry over HTTPS. No backend, no database of lookups, fully private.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// RDAP (Registration Data Access Protocol) client logic — pure functions only.
// The network fetch lives in the Whois island, not here. This module is the
// unit-test surface for the WHOIS tool and is mirrored by cli/whois (Go twin).
// Pure + deterministic, never throws.
//
// RDAP is the HTTPS/JSON successor to WHOIS (RFCs 7480-7485). The browser
// queries authoritative RDAP servers directly; CosmoDev runs no backend.
// See docs/knowledge-base/whois-to-rdap.md and rdap-in-the-browser.md.

// ---------------------------------------------------------------------------
// Input classification
// ---------------------------------------------------------------------------

export type InputKind = 'domain' | 'ipv4' | 'ipv6' | 'asn' | 'invalid';

export interface ClassifiedInput {
  kind: InputKind;
  /** Normalized object string ready for an RDAP path segment (lowercased domain, bare IP, bare ASN number). */
  value: string;
  /** Top-level domain (last label) for domain inputs. Undefined otherwise. */
  tld?: string;
}

const ASN_RE = /^(?:as)?(\d{1,10})$/i;
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const IPV6_RE = /^[0-9a-fA-F:]+$/;
// A label: letters, digits, hyphens; no leading/trailing hyphen; IDN A-labels (xn--).
const LABEL_RE = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)$/i;

function isValidIpv4(ip: string): boolean {
  const m = IPV4_RE.exec(ip);
  if (!m) return false;
  return m.slice(1).every((octet) => {
    const n = Number(octet);
    return n <= 255 && !/^0\d/.test(octet); // regex already bounds length and sign
  });
}

function isValidIpv6(ip: string): boolean {
  if (!ip.includes(':')) return false;
  if (!IPV6_RE.test(ip)) return false;
  // At most one '::' collapse, and 0-8 groups otherwise. Reject malformed.
  const parts = ip.split('::');
  if (parts.length > 2) return false;
  const [head = '', tail = ''] = parts;
  // A stray colon either side of the '::' means 3+ consecutive colons (invalid).
  if (head.endsWith(':') || tail.startsWith(':')) return false;
  const headGroups = head ? head.split(':') : [];
  const tailGroups = tail ? tail.split(':') : [];
  const allGroups = [...headGroups, ...tailGroups];
  if (allGroups.length > 8) return false;
  return allGroups.every((g) => g === '' || /^[0-9a-fA-F]{1,4}$/.test(g));
}

/** Strip scheme, userinfo, path, and optional port down to the bare host. */
function stripToHost(raw: string): string {
  let s = raw.trim();
  s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); // scheme:// (http, https, …)
  s = s.replace(/^(?:whois|rdap):/i, ''); // bare whois:/rdap: prefix
  if (s.includes('@')) s = s.slice(s.lastIndexOf('@') + 1); // drop userinfo@
  s = s.replace(/[/?#].*$/, ''); // drop path / query / fragment
  const bracketed = /^\[([0-9a-fA-F:.]+)\](?::\d+)?$/.exec(s); // [ipv6](:port)?
  if (bracketed) return bracketed[1];
  s = s.replace(/^([^:[\]]*\.[^:[\]]*):\d+$/, '$1'); // dotted host with port -> host
  return s;
}

/**
 * Classify a user-typed lookup target. Never throws. Accepts domains
 * (with scheme/path/port tolerance), IPv4, IPv6, and AS numbers (with or
 * without an "AS" prefix). Returns { kind: 'invalid' } for anything else.
 */
export function classifyInput(raw: string): ClassifiedInput {
  const empty: ClassifiedInput = { kind: 'invalid', value: '' };
  if (raw == null) return empty;
  const host = stripToHost(String(raw));
  if (host === '') return empty;

  // ASN: bare or AS-prefixed number with no dots.
  const asnMatch = ASN_RE.exec(host);
  if (asnMatch) {
    const num = Number(asnMatch[1]);
    if (num > 0) return { kind: 'asn', value: String(num) };
  }

  // IPv4
  if (isValidIpv4(host)) return { kind: 'ipv4', value: host };

  // IPv6
  if (isValidIpv6(host)) return { kind: 'ipv6', value: host.toLowerCase() };

  // Domain: must have a dot, a valid TLD label, and every label valid.
  const normalized = host.toLowerCase().replace(/\.+$/, ''); // strip trailing root dot(s)
  if (!normalized.includes('.')) return empty;
  const labels = normalized.split('.');
  if (labels.some((label) => !LABEL_RE.test(label))) return empty;
  return { kind: 'domain', value: normalized, tld: labels[labels.length - 1] };
}

/** Last label of a domain (the TLD). Undefined for non-domains or dotless strings. */
export function extractTld(domain: string): string | undefined {
  const d = String(domain).toLowerCase().replace(/\.+$/, '');
  if (!d.includes('.')) return undefined;
  const labels = d.split('.');
  return labels[labels.length - 1] || undefined;
}

// ---------------------------------------------------------------------------
// IANA bootstrap (finding the authoritative RDAP server)
// ---------------------------------------------------------------------------

export interface BootstrapEntry {
  /** Match keys — TLDs (dns.json), CIDRs (ipv4/ipv6.json), or ASN ranges (asn.json). */
  keys: string[];
  urls: string[];
}
export type Bootstrap = BootstrapEntry[];

/**
 * Parse an IANA RDAP bootstrap file (dns.json / ipv4.json / ipv6.json / asn.json)
 * into a normalized list of { keys, urls } entries. Never throws — returns []
 * for any malformed input. Tolerates both `.services` (current) and `.reg2` shapes.
 */
export function parseBootstrap(json: unknown): Bootstrap {
  if (!json || typeof json !== 'object') return [];
  const root = json as Record<string, unknown>;
  const services = (root.services ?? root.reg2) as unknown;
  if (!Array.isArray(services)) return [];
  const out: Bootstrap = [];
  for (const entry of services) {
    if (!Array.isArray(entry) || entry.length < 2) continue;
    const [keysRaw, urlsRaw] = entry;
    if (!Array.isArray(keysRaw) || !Array.isArray(urlsRaw)) continue;
    const keys = keysRaw.filter((k): k is string => typeof k === 'string');
    const urls = urlsRaw.filter((u): u is string => typeof u === 'string');
    if (keys.length && urls.length) out.push({ keys, urls });
  }
  return out;
}

/** Resolve a server by exact case-insensitive key match (the dns.json path). */
export function resolveServer(bootstrap: Bootstrap, key: string): string | undefined {
  const lower = key.toLowerCase();
  for (const entry of bootstrap) {
    if (entry.keys.some((k) => k.toLowerCase() === lower)) return entry.urls[0];
  }
  return undefined;
}

// --- IPv4 / IPv6 CIDR containment ----------------------------------------

function ipv4ToInt(ip: string): number {
  const [a, b, c, d] = ip.split('.').map(Number);
  // eslint-disable-next-line no-bitwise
  return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
}

function ipv6ToBigInt(ip: string): bigint | undefined {
  // Split on the (at most one) '::' collapse, then parse up to 8 hex groups.
  const idx = ip.indexOf('::');
  const head = idx === -1 ? ip : ip.slice(0, idx);
  const tail = idx === -1 ? '' : ip.slice(idx + 2);
  const headGroups = head ? head.split(':') : [];
  const tailGroups = tail ? tail.split(':') : [];
  const missing = 8 - (headGroups.length + tailGroups.length);
  if (missing < 0) return undefined; // more than 8 groups specified
  const groups = [...headGroups, ...Array(missing).fill('0'), ...tailGroups];
  let n = 0n;
  for (const g of groups) {
    if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return undefined;
    n = (n << 16n) | BigInt(parseInt(g, 16));
  }
  return n;
}

function maskBits(bits: number, total: number): bigint {
  if (bits <= 0) return 0n;
  if (bits >= total) return (1n << BigInt(total)) - 1n;
  return ((1n << BigInt(bits)) - 1n) << BigInt(total - bits);
}

/** True when `ip` falls inside the CIDR `cidr` (IPv4 or IPv6, detected by syntax). */
export function cidrContains(cidr: string, ip: string): boolean {
  const [range, bitsStr] = cidr.split('/');
  const bits = bitsStr === undefined ? (range.includes(':') ? 128 : 32) : Number(bitsStr);
  if (Number.isNaN(bits)) return false;
  if (range.includes(':')) {
    const net = ipv6ToBigInt(range.toLowerCase());
    const target = ipv6ToBigInt(ip.toLowerCase());
    if (net === undefined || target === undefined) return false;
    const mask = maskBits(bits, 128);
    // eslint-disable-next-line no-bitwise
    return (net & mask) === (target & mask);
  }
  if (!isValidIpv4(range) || !isValidIpv4(ip)) return false;
  // eslint-disable-next-line no-bitwise
  const mask = Number(maskBits(bits, 32) & 0xffffffffn);
  // eslint-disable-next-line no-bitwise
  return (ipv4ToInt(range) & mask) === (ipv4ToInt(ip) & mask);
}

/** Resolve the authoritative RDAP server for an IPv4/IPv6 address via a parsed bootstrap. */
export function resolveIpServer(bootstrap: Bootstrap, ip: string): string | undefined {
  for (const entry of bootstrap) {
    if (entry.keys.some((cidr) => cidrContains(cidr, ip))) return entry.urls[0];
  }
  return undefined;
}

// --- ASN range containment ------------------------------------------------

function asnInRange(rangeKey: string, asn: number): boolean {
  const r = rangeKey.trim();
  if (r.includes('-')) {
    const [lo, hi] = r.split('-').map((n) => Number(n));
    return asn >= lo && asn <= hi;
  }
  return Number(r) === asn;
}

/** Resolve the authoritative RDAP server for an ASN (number) via a parsed bootstrap. */
export function resolveAsnServer(bootstrap: Bootstrap, asn: number): string | undefined {
  for (const entry of bootstrap) {
    if (entry.keys.some((k) => asnInRange(k, asn))) return entry.urls[0];
  }
  return undefined;
}

// ---------------------------------------------------------------------------
// RDAP URL construction
// ---------------------------------------------------------------------------

export type RdapObjectPath = 'domain' | 'ip' | 'autnum';

/** The RDAP path segment type for a classified input. */
export function objectPath(input: ClassifiedInput): RdapObjectPath | undefined {
  switch (input.kind) {
    case 'domain':
      return 'domain';
    case 'ipv4':
    case 'ipv6':
      return 'ip';
    case 'asn':
      return 'autnum';
    default:
      return undefined;
  }
}

/**
 * Build an RDAP query URL. `baseUrl` is the authoritative server (from the
 * bootstrap); exactly one slash separates base and the object path.
 */
export function buildRdapUrl(baseUrl: string, input: ClassifiedInput): string {
  const path = objectPath(input);
  if (!path) return '';
  const base = baseUrl.replace(/\/+$/, '');
  return `${base}/${path}/${input.value}`;
}

// ---------------------------------------------------------------------------
// RDAP response normalization
// ---------------------------------------------------------------------------

export interface WhoisEvent {
  action: string;
  date?: string;
  actor?: string;
}

export interface WhoisEntity {
  roles: string[];
  handle?: string;
  name?: string;
  email?: string;
}

export interface WhoisNameserver {
  ldhName?: string;
  hostName?: string;
}

export interface WhoisRecord {
  objectClass: string;
  ldhName?: string;
  unicodeName?: string;
  handle?: string;
  status: string[];
  events: WhoisEvent[];
  entities: WhoisEntity[];
  nameservers: WhoisNameserver[];
  secureDNS?: { delegationSigned?: boolean; keyTag?: number };
  notices: { title?: string; description?: string }[];
}

function asStringArray(value: unknown): string[] {
  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
}

/** Pull `fn` (name) and the first `email` out of a jCard / vCard array. */
function parseVcard(vcardArray: unknown): { name?: string; email?: string } {
  if (!Array.isArray(vcardArray) || vcardArray.length < 2 || !Array.isArray(vcardArray[1])) return {};
  let name: string | undefined;
  let email: string | undefined;
  for (const prop of vcardArray[1] as unknown[]) {
    if (!Array.isArray(prop) || prop.length < 4) continue;
    const [kind, , , value] = prop as [string, unknown, unknown, unknown];
    if (kind === 'fn' && typeof value === 'string' && !name) name = value;
    if (kind === 'email' && typeof value === 'string' && !email) email = value;
  }
  return name || email ? { name, email } : {};
}

/** Best-effort normalize an RDAP JSON response into a stable WhoisRecord. Never throws. */
export function normalizeRdapResponse(json: unknown): WhoisRecord {
  const empty: WhoisRecord = {
    objectClass: 'unknown',
    status: [],
    events: [],
    entities: [],
    nameservers: [],
    notices: [],
  };
  if (!json || typeof json !== 'object') return empty;
  const o = json as Record<string, unknown>;

  const events: WhoisEvent[] = Array.isArray(o.events)
    ? (o.events as unknown[])
        .map((e) => {
          if (!e || typeof e !== 'object') return undefined;
          const ev = e as Record<string, unknown>;
          return {
            action: typeof ev.eventAction === 'string' ? ev.eventAction : '',
            date: typeof ev.eventDate === 'string' ? ev.eventDate : undefined,
            actor: typeof ev.eventActor === 'string' ? ev.eventActor : undefined,
          };
        })
        .filter((e): e is WhoisEvent => e !== undefined && e.action !== '')
    : [];

  const entities: WhoisEntity[] = Array.isArray(o.entities)
    ? (o.entities as unknown[])
        .map((en) => {
          if (!en || typeof en !== 'object') return undefined;
          const e = en as Record<string, unknown>;
          const vcard = 'vcardArray' in e ? parseVcard(e.vcardArray) : {};
          return {
            roles: asStringArray(e.roles),
            handle: typeof e.handle === 'string' ? e.handle : undefined,
            name: vcard.name,
            email: vcard.email,
          };
        })
        .filter((en): en is WhoisEntity => en !== undefined)
    : [];

  const nameservers: WhoisNameserver[] = Array.isArray(o.nameservers)
    ? (o.nameservers as unknown[])
        .map((ns) => {
          if (!ns || typeof ns !== 'object') return undefined;
          const n = ns as Record<string, unknown>;
          return {
            ldhName: typeof n.ldhName === 'string' ? n.ldhName : undefined,
            hostName: typeof n.hostName === 'string' ? n.hostName : undefined,
          };
        })
        .filter((n): n is WhoisNameserver => n !== undefined)
    : [];

  let secureDNS: WhoisRecord['secureDNS'];
  if (o.secureDNS && typeof o.secureDNS === 'object') {
    const s = o.secureDNS as Record<string, unknown>;
    secureDNS = {
      delegationSigned: typeof s.delegationSigned === 'boolean' ? s.delegationSigned : undefined,
      keyTag:
        Array.isArray(s.dsData) && s.dsData.length
          ? Number((s.dsData[0] as Record<string, unknown>)?.keyTag) || undefined
          : undefined,
    };
  }

  const notices = Array.isArray(o.notices)
    ? (o.notices as unknown[])
        .map((nt) => {
          if (!nt || typeof nt !== 'object') return undefined;
          const n = nt as Record<string, unknown>;
          const desc = Array.isArray(n.description) ? n.description.join(' ') : undefined;
          return {
            title: typeof n.title === 'string' ? n.title : undefined,
            description: typeof desc === 'string' ? desc : undefined,
          };
        })
        .filter((n): n is NonNullable<typeof n> => n !== undefined)
    : [];

  return {
    objectClass: typeof o.objectClassName === 'string' ? o.objectClassName : 'unknown',
    ldhName: typeof o.ldhName === 'string' ? o.ldhName : undefined,
    unicodeName: typeof o.unicodeName === 'string' ? o.unicodeName : undefined,
    handle: typeof o.handle === 'string' ? o.handle : undefined,
    status: asStringArray(o.status),
    events,
    entities,
    nameservers,
    secureDNS,
    notices,
  };
}

/** Pull the common registration milestones out of an event list. */
export function summarizeEvents(events: WhoisEvent[]): {
  registration?: string;
  expiration?: string;
  lastChanged?: string;
  transferred?: string;
} {
  const find = (action: string) => events.find((e) => e.action.toLowerCase() === action)?.date;
  return {
    registration: find('registration'),
    expiration: find('expiration'),
    lastChanged: find('last changed'),
    transferred: find('transfer'),
  };
}

const RDAP_OBJECT_PATH_RE = /\/(domain|ip|autnum|nameserver|entity)\/[^/]+/;

/** True when the relay may fetch this URL: https only, no userinfo, host on
 *  the bootstrap allowlist (exact match or a label-boundary subdomain — a
 *  bare string suffix would admit attacker registrations), and an RDAP
 *  object-path shape (/domain/…, /ip/…, /autnum/…, /nameserver/…, /entity/…). */
export function isAllowlistedRdapUrl(rawUrl: string, bootstrap: Bootstrap): boolean {
  let u: URL;
  try {
    u = new URL(rawUrl);
  } catch {
    return false;
  }
  if (u.protocol !== 'https:' || u.username !== '' || u.password !== '') return false;
  if (!RDAP_OBJECT_PATH_RE.test(u.pathname)) return false;
  const host = u.hostname.toLowerCase();
  return bootstrap.some((entry) =>
    entry.urls.some((server) => {
      let su: URL;
      try {
        su = new URL(server);
      } catch {
        return false;
      }
      const allowed = su.hostname.toLowerCase();
      return host === allowed || host.endsWith('.' + allowed);
    })
  );
}

/** Relative relay URL for a validated RDAP URL (isAllowlistedRdapUrl first). */
export function buildRelayUrl(rdapUrl: string): string {
  return `/api/rdap?url=${encodeURIComponent(rdapUrl)}`;
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →