toRegistrableDomain("www.google.uk.com") returned "uk.com" (the ICANN eTLD+1) even though lookup() routes google.uk.com to CentralNic's uk.com sub-registry, not .com; callers who normalized a hostname before calling lookup() got an input that resolves to the wrong registry. It now returns "google.uk.com". getDomainTld likewise returns the sub-registry suffix ("uk.com") for names registered there, matching the tld a lookup reports, so the two are consistent.
Some registries (e.g. ISNIC) populate a contact's name or organization with a registry handle like "JJ1234-IS" instead of a real name. finalizeContact now strips such values via a new isRegistryHandle helper rather than treating them as redacted: the registry published a reference, not a withheld value, so redacted stays unset and the contact is not considered hidden.
RDAP related/entity links come from upstream responses and are now validated before being followed: only http(s) URLs pointing to a public hostname or IP literal without embedded credentials are fetched. Links that fail this check are silently skipped. The IANA bootstrap servers (and customBootstrapData entries) are trusted and not checked.
customBootstrapData now treats null and undefined as "not provided" rather than erroring, so a failed cache read can be passed straight through without a guard; a non-null/non-undefined object without a services array still fails the lookup with invalid_input.
A registry that doesn't hold a name can answer with the delegation above it: Nominet's WHOIS gave ac.uk's record (registrant JNT Association, registered before 1996, ja.net nameservers) when asked for ox.ac.uk, and it parsed as ox.ac.uk's own record. When the reply's "Domain name:" is a parent of the queried name, fail with unparseable instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like ac.uk, these suffixes aren't run by Nominet, whose RDAP server (the only one IANA lists for uk) answers 404 for names under them, so england.nhs.uk and met.police.uk came back as available. Nominet's WHOIS says outright that it isn't the registry for them. Their operators publish neither RDAP nor WHOIS, so list them as sub-registries with no servers: lookups now fail with no_server instead of guessing (whoisHints can still name a server).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IANA's bootstrap lists only uk, so ac.uk names fell back to Nominet's RDAP server, which answers 404 for them: ox.ac.uk and cam.ac.uk came back as not registered. Nominet's WHOIS is no better, answering with the ac.uk delegation itself. Jisc runs ac.uk and publishes WHOIS (no RDAP) at whois.ja.net, so route the suffix there as a sub-registry, which may now leave out either server.
Parse Jisc's tab-indented layout: "Servers:" nameservers with glue, "Entry created/updated:", "Registered By:", "Registered For:"/"Domain Owner:" as the registrant's organization, "Registrant Contact:", dates with a leading weekday ("Wednesday 26th Jul 2028"), and "No such domain" as unregistered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Subdomains and bare public suffixes (www.example.com, co.uk) were accepted as lookup inputs and returned "not registered" because the registry sees no record for them. Reject them with invalid_input before querying, naming the registrable domain so callers know what to look up instead.
With rdapOnly, any RDAP failure — rate_limited, http_error, timeout — was reported as rdap_unavailable regardless. rdap_unavailable is now reserved for a TLD with no RDAP bootstrap entry; transient failures surface their own code (and phase/server) so callers can tell a throttle from a missing server.
isPrivacyContact checked whether name or organization appeared in redactedFields, so a domain whose registrant had a redacted email but a visible "Google LLC" organization was flagged private. The check now asks whether the registrant's identity is actually hidden: a privacy or proxy service is named, or nothing identifying (no name, no organization) remains after redaction.
The helpers threw a plain Error with only the message, so callers couldn't tell
a rate limit from invalid input or their own abort. Throw an RdapperError whose
code, phase, server, and retryAfterMs mirror the lookup result (an abort keeps
the name "AbortError"), and export RdapperError for instanceof checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With both set, RDAP was skipped and WHOIS queried, breaking rdapOnly's
guarantee (on edge runtimes it failed with a confusing unsupported_runtime).
Fail with invalid_input before querying anything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging a registry and a registrar reply kept the earliest creation and the
latest updated/expiration dates across both, while RDAP reports the registry's.
The same domain therefore differed by source: google.com's updated date was
2019 over RDAP but MarkMonitor's 2024 over WHOIS, and amnic.net's creation came
from a registrar printing local time without a zone. Keep the registry's dates
and fill only what it lacks from the registrar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI looked up only the last argument (an option like --rdap-only was taken
as the domain), always exited 0, and started every stdin line's lookup at once,
so results could print out of order and many lines hit registries together.
Look up each argument, or each non-blank stdin line, one at a time in order;
exit 1 when any lookup fails and 2 on an unknown option.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Queried interval is too short." (CNNIC) and DENIC's "access control limit
reached" weren't recognized, so a throttled .cn or .de lookup failed as
"unparseable" instead of "rate_limited". .cn has no RDAP, so its WHOIS
throttling was always misreported. The texts are from known registry replies,
not captured in this session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UND_ERR_HEADERS_TIMEOUT and UND_ERR_BODY_TIMEOUT (undici's own limits, reached
when timeoutMs is off or above them) were reported as "unknown" rather than
"timeout", and UND_ERR_SOCKET as "unknown" rather than "connect_failed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README says tldts options such as allowPrivateDomains pass through, but any
private suffix still failed the "is ICANN" check, so
toRegistrableDomain("foo.github.io", { allowPrivateDomains: true }) returned
null instead of "foo.github.io".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any AbortError from the bootstrap fetch was rethrown, so a customFetch's own
timeout ended the whole lookup as "aborted" (documented as "your own signal
fired") with no WHOIS fallback; the same error during the RDAP domain fetch
already fell back. Caller aborts and deadlines are still rethrown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A null among an RDAP response's nameservers or DS records threw while
normalizing; the error was swallowed, and with rdapOnly the lookup reported "no
RDAP server listed in the IANA bootstrap" although a server had answered. Skip
null entries, and when a response arrived but couldn't be read, say so with its
error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fetched documents' own links were never followed (merging dropped them), so
maxRdapLinkHops above 1 did nothing, while every link of one document was
fetched regardless of the limit, contradicting its documented meaning
("maximum related link fetches") and the README's timeout arithmetic.
Follow links breadth-first, including those in fetched documents, up to
maxRdapLinkHops fetches in all, "related" links first. Skip a document's self
link, and don't fall back to a link's "value", which is the context (the
document itself), not the target. A null entry in "links" no longer throws.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check for an explicit zone took the year in "Aug-1996" for a "-1996"
offset, so the value was parsed in the host's local time; a numeric offset now
counts only after a time of day. Nominet's "before Aug-1996" (domains older than
its records) also became a date; a leading "before"/"after"/"since" now means
there is none. Tests pass under UTC, New York, Tokyo, Chatham, and Santiago.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Discovery used the whole public suffix as its key, so for example.co.uk it
asked IANA about "co.uk" (IANA's database has only TLDs) and never consulted a
"uk" whoisHints entry or curated exception: WHOIS lookups under co.uk failed
with no_server. Ask IANA about the TLD, and let hints and exceptions be keyed
by either. Checked live: bbc.co.uk now resolves through whois.nic.uk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bare "registrant:"/"holder:" key holds a contact handle on many registries
(.se, .si, .is, .at, .mk, .cz, .ve), which became the registrant's name
("NG8867695-NICAT", "CZ-NIC"). Skip handle-like values for that key; .uk's
"Registrant:" section, which holds the name, still reads. Recognize .be's "Not
shown, please visit ..." and .th's "Personal Information" as placeholders, so
those fields are reported redacted instead of shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
record.registrar came from the first registrar entity, usually the registry's
copy with only a name and IANA ID, so details in the registrar's own document
(URL, address, phone) never appeared. Fill the first copy's gaps from later
ones with the same IANA ID, and fall back to the abuse contact only after that.
Checked live: google.com's registrar now carries MarkMonitor's address.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a lookup merges a registry's and a registrar's data (RDAP related links,
WHOIS referrals), the same party can appear twice, usually redacted at the
registry and complete at the registrar. RDAP kept both ([registrant, tech,
registrant, tech] for thick gTLDs), while the WHOIS merge deduped by
type|organization|name|email, which kept the registry's redacted registrant
over the registrar's full one when only the organization matched, yet kept an
empty redacted admin beside the real one.
Add mergeContacts, used by both: contacts of the same role whose name,
organization, and email don't conflict are combined, the fuller one winning, and
a field stays in redactedFields only if neither supplied it. RDAP also yields a
contact for each role of a multi-role entity ("administrative" + "technical")
instead of only the first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A street component holding several values is an array (RFC 7095 3.3.1.3), which
was dropped, so contacts and registrars lost their street entirely. Read every
value, and fall back to the "label" parameter when the structured address is
empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replies were always decoded as UTF-8, so registries that answer in Latin-1
(.br, .fi) lost every accented character to U+FFFD ("N�cleo de Informação").
Fall back to windows-1252 when the reply isn't valid UTF-8; a character cut off
by a read timeout doesn't count against UTF-8. Checked live: registro.br and
traficom.fi now decode cleanly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.lu names its registrar under "registrar-name", which wasn't read. The bare
"organisation" key matched any contact's organisation (.be took its tech
contact's "DNS Belgium vzw"); registrar sections are now read as "registrar
name" instead, and dropping the key changes nothing else across the saved
corpus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.it's registrar section says "DNSSEC: yes" when the registrar supports DNSSEC,
and that line was read instead of the domain's own "Signed: no", so most
unsigned .it domains looked signed. Read DNSSEC from the first block that has
it, preferring "Signed"; accept .kr's Korean "서명" ("signed"), which now comes
first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add the keys .th ("Created date", "Exp date"), .sk ("Valid Until"),
.by ("Update Date"), and .kr ("Last Updated Date") use; all were missing.
- Take the latest value for the updated date: .il lists every "changed:" line
oldest first, so its updated date was 1996.
- Keep the time in compact "20200427 16:03:40" dates (.at).
- Don't hand the native parser a word next to a year: .ro's "Before 2001"
became 2001-01-01.
Across the saved corpus only those fields changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nameservers were missing for .pl, .be, .it ("nameservers"), .hk ("Name Servers
Information"), .kr ("Host Name"), and .tm past "NS 4"; .pl kept only its first
host because each continuation line's IPv6 colon made it a new key. Glue was
read only as bare space-separated addresses, so it was lost when written in
parentheses (.cz, .cl, .mk, .ve, .si), brackets (.lu, .lt, .pl), or with commas
(.ru's IPv4, .mx's IPv6). .dk listed the domain itself (its "DNS:" line) as a
nameserver.
Add the keys, take glue from any of those shapes, treat an indented line whose
colons aren't key separators as a continuation, and skip entries that aren't
hostnames or name the queried domain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line parser only understood "Key:" headers with indented values, so:
- values placed unindented under a header were lost (.bg nameservers, .pl
"REGISTRAR:"), or became garbage keys when they held a colon (an IPv6 glue
address)
- colon-less headers (.it "Nameservers", "Registrar") were skipped, and their
indented lines were appended to whatever key came before (.it's hosts landed
in "dnssec")
- nested "Name:"/"Website:" lines under a "Registrar:" header (.eu, .be, .it,
.uk) were recorded only as bare keys, so .be's registrar name came from the
tech contact's "Organisation"
Read unindented values right under a header (or a single token after a blank
line, as .hk does), treat short colon-less lines as headers, and also record
nested keys with their header's name ("registrar name"). Across the saved
corpus this fills the registrar for .pl, .be, .eu, and .it, contacts for .it,
.mx, and .eu, and nameservers for .bg.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Statuses came from whichever key matched first across the whole reply, and only
a value's first word was kept:
- .ua listed the registrant contact's statuses, and .mx the contacts'
"State: Nuevo Leon" as statuses ["Nuevo", ...]
- .fr showed "ok" from "status: ACTIVE" and missed its "eppstatus" EPP codes
- .be showed "NOT" and missed the EPP codes under its "Flags:" header
- comma lists kept one code with a trailing comma (.sk "serverRenewProhibited,"),
and phrases lost all but a word (.il "Transfer", .rs "Registry", .cz
"Sponsoring")
Read statuses from the first block that has any, preferring "eppstatus" and
taking "state" only beside the domain line; add .be's EPP flags; split comma
lists; keep a registry's own phrase whole; map FRED's "Sponsoring registrar
change forbidden" (confirmed against .cz RDAP) and its siblings to EPP codes;
and derive transferLock from the EPP code as well as the raw text. .fr, .be,
and .cz now report the transfer lock RDAP does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A weak word ("private", "protection") plus a generic context word ("services",
"contact") counted as a privacy service, so "Tata Consultancy Services Private
Limited" (the standard Indian company form), "Allied Fire Protection Services",
and "Contact Lens Protection Ltd" got privacyService/redacted/privacyEnabled.
"privado" and "for privacy" matched as substrings ("Banco Privado Atlántico",
"Center for Privacy and Technology").
Keep only domain-specific context words, ignore legal forms like "Private
Limited", "Pvt. Ltd.", and "Pte. Ltd.", and match Spanish services by phrase.
No change across the saved WHOIS corpus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Availability phrases were matched anywhere in the reply, so a registered record
whose remarks or footer used one was reported available: nic.ir ("only
available for registration under certain conditions"), or any record with
"Registrant Fax: not found" or "This WHOIS service is free of charge". The
referral chain used the same check and could drop a registrar's reply.
Phrases now count only when the reply has no registration data (dates,
registrar, nameservers); explicit statuses such as "status: free" or .br's
"release process: waiting" still win. Checked live: unregistered names under
.com, .ir, .it, .jp, .fi, .cz, .nl, and .br are still reported available.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verisign writes ldhName in uppercase, so record.domain and punycodeName were
"GOOGLE.COM" over RDAP but "google.com" over WHOIS or a 404. A document without
ldhName fell back to its handle, giving names like "2336799_DOMAIN_COM-VRSN".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A registrar document for an IDN carries unicodeName ("münchen.com"), which was
compared with the punycode query and never matched, so gTLD IDNs silently lost
the registrar's data (contacts, on thin .com/.net). Meanwhile documents with no
name at all, like the entity or help objects behind "entity"/"registrar" links,
passed and had their statuses and events merged into the domain.
Require a domain object whose ldhName/unicodeName matches the query in punycode
form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isLikelyDomain validated a trimmed copy but lookup sent the raw string, so
" example.com" or a CRLF-terminated line was queried as "%20example.com" /
"example.com%0D" (a 404 there reads as "not registered"). Unicode names and a
trailing dot were rejected as invalid_input even though toRegistrableDomain
returns them, and IPs passed as domains.
Query the trimmed, lowercase punycode form (via the WHATWG URL parser, so edge
runtimes work too), accept Unicode and trailing dots in isLikelyDomain, and
reject an all-numeric TLD. Drop the unused punyToUnicode, which only applied
NFC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both normalizers deduped nameservers with uniq(), which compares object
identity and so removed nothing: WHOIS records for .eu, .at, .cl, and .ua listed
hosts twice (glue blocks, one line per address), and .ru/.pl hosts kept their
trailing dot. Merge by lowercase host without a trailing dot, unioning glue
addresses, in RDAP, WHOIS, and the WHOIS referral merge alike.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.fi writes every key as "created............: 15.8.2017" and .no several, and
the padding stayed in the key, so no field matched: .fi lookups failed as
"unparseable" and .no lost its DNSSEC flag. Strip trailing runs of dots from
keys. Across the saved corpus only norid.no's DNSSEC changed; .fi now parses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The check /signed|yes|true/ matched "unsigned", so every unsigned domain looked
signed over WHOIS (google.com, wikipedia.org, nic.xyz, ...) while RDAP said
otherwise. Require the value to start with signed/yes/true/active, read .rs's
"DNSSEC signed" key, and accept any of several values (.kr repeats the field in
Korean).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
normalizeRdap always left record.reseller undefined, though reseller-role
entities (e.g. SIDN for .nl) were already parsed as contacts. Take the
reseller's organization or name from that contact. WHOIS also reads the
"Reseller Name" key used by .au and .th.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StatusEvent.status is documented as the EPP code ("clientTransferProhibited"),
which WHOIS produced, but RDAP records carried RDAP's spelling ("client transfer
prohibited", "active"), so the same domain had different statuses per source.
Map RDAP values to EPP codes per RFC 8056 ("active" is "ok"), keeping the source
spelling in raw, and dedupe statuses a merged registry/registrar document lists
twice. WHOIS also reads spelled-out statuses as a phrase instead of keeping only
the first word, and ccTLD "Active" becomes "ok".
Values with no EPP equivalent are unchanged. normalizeEppStatus is exported for
upgrading statuses stored by older versions.
BREAKING CHANGE: RDAP statuses[].status values change from RDAP spellings to EPP
codes (the RDAP spelling remains in statuses[].raw).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registries running CZ.NIC's FRED (.cz, .mk, .ve, ...) list the domain block
first and repeat "created:" in every contact, nsset, and keyset block. All
blocks were merged into one map and "created" outranks "registered", so the
creation date came from the registrant contact (.cz, .ve) or a redaction
placeholder (.mk). Read dates block by block, earliest first, taking the first
value that parses, with the whole-reply map as a fallback.
Also read .tw's "Record created/expires on ..." lines, which have no key
separator, so .tw records had no dates at all.
Checked against 69 saved WHOIS replies: only the .cz, .mk, .ve, and .tw dates
changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CentralNic ends every reply with "Access to the Whois and RDAP services is rate
limited", so any of its records under the 2 KB refusal cutoff (e.g. nic.xyz,
google.uk.com) failed as rate_limited. A reply that parses into record data is
an answer, whatever its footer says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names like google.uk.com are registered with CentralNic, not Verisign, but the
ICANN-only public suffix routed them by "com": RDAP asked Verisign, got a 404,
and reported them as available. The uk.com/us.com WHOIS exceptions were never
reachable for the same reason.
Route names under CentralNic's sub-registries (its Public Suffix List entries,
each confirmed against rdap.centralnic.com) by that suffix, to CentralNic's RDAP
and WHOIS, ahead of the IANA bootstrap and IANA WHOIS discovery. record.tld is
the sub-registry suffix (e.g. "uk.com").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The native Date fallback read values without a zone in local time, so the same
WHOIS text produced different timestamps on different hosts (every .jp date,
"YYYY/MM/DD", went through it) and day-first dates were read in US order or
dropped. Replace the content-sniffing parser with explicit per-format builders:
- year-first and day-first dates with -, /, . separators (.jp, .kr, .rs, .mk, .pt),
rejecting out-of-range fields instead of rolling over
- zone suffixes "(JST)", "(UTC+8)", "CLST"/"CLT", and "+0000Z"
- anything left for the native parser is pinned to UTC
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gTLD registries (per the ICANN RDAP profile) and CIRA nest the registrar's
abuse contact inside the registrar entity, which normalizeRdap never looked
at, so RDAP records lacked the email/phone that WHOIS records carry. Fall back
to that nested abuse entity when the registrar's own vCard has neither.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verisign indents every line of its WHOIS output, so the referral patterns
(anchored at column 0) never matched and .com/.net lookups stopped at the thin
registry record without registrant data. Allow leading whitespace and keep the
value on the same line so a blank field no longer borrows the next line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Delegate `exports` map generation to tsdown (`exports: true`) and simplify `package.json` to a bare `"." : "./dist/index.mjs"` entry plus `"./package.json"`; drop redundant `main`/`module` fields
- Add `sideEffects: false` to `package.json` so bundlers can tree-shake the package
- Enable `clean: true` and `failOnWarn: true` in `tsdown.config.ts` for a cleaner build and stricter warning enforcement
- Suppress the TypeScript 7.0 experimental-API warning emitted by tsdown
- Bump `typescript` dev dependency to `^7.0.2`