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 →