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`
- Assert that bare "Not Available" / "No Data" phrases only match as a complete value and not substrings (e.g. `"Address not available in this region"` or `"No Data Corp"` are not placeholders); confirm `isPrivacyName` ignores them
- Assert `redactionFields("Registrant Country Code")` → `["country"]` and that a comma-separated multi-field name resolves all three constituent fields
- Assert that a contact with a placeholder country but a real `countryCode` has the country resolved from the code, records `"country"` in `redactedFields`, and produces identical output when passed through `finalizeContact` a second time
- Document `finalizeContact` and `isPrivacyContact` in README
- Extend `ContactField` union and `REPORTED_FIELDS`/`ADR_INDEX_FIELDS`/`VCARD_PROPERTY_FIELDS` to include `"country"` so RFC 9537 redactions targeting the country field and vCard `adr[6]` are now tracked in `redactedFields`
- Drop placeholder country values (`"N/A"`, `"REDACTED FOR PRIVACY"`, etc.) in `finalizeContact`, but only after confirming the value isn't a real country code or name (e.g. `"NA"` resolves to Namibia and is kept)
- Seed `redactedFields` and `redacted` from the contact's existing values at the start of `finalizeContact` so calling it twice on the same contact produces identical output (idempotent)
- Expand `PLACEHOLDER_VALUE_PATTERNS` to match bare dot sequences (`".."`) and whole-value-only phrases (`"Not Available"`, `"Not Published"`, `"Not Public"`, `"No Data"`) without risk of false-positives on substrings like `"No Data Corp"`
- Export `finalizeContact` and `isPrivacyContact` from the package entry point
- Extend `redactionFields(name, prePath?)` to parse the vCard property from an RFC 9537 `prePath` expression (e.g. `[?(@[0]=='email')]`) when the human-readable name alone doesn't identify fields; add `ADR_INDEX_FIELDS` to narrow a specific address element (`[3][N]`) to the right `ContactField`
- Pass `r.prePath` through in `extractContacts` so redactions with generic names (e.g. `"Redacted"`) still populate `redactedFields` correctly
- Split `PRIVACY_STRONG_KEYWORDS` source into `REDACTION_PHRASES` (text that stands in for a withheld value) and `PRIVACY_SERVICE_PHRASES` (proxy/shield names); derive `PLACEHOLDER_VALUE_PATTERNS` entries from `REDACTION_PHRASES` with `\b…\b` anchoring so the two lists can't drift apart
- Drop `!!registrant?.redacted` from the `privacyEnabled` check in `normalizeRdap`; `isPrivacyContact` and the RFC 9537 redaction scan already cover the same signal without double-counting
- Expand `isPlaceholderValue` test to assert that bare tokens (`"Unknown"`, `"Na"`) only match the whole value and not substrings like `"Unknown Pleasures Ltd"` or `"Na Health Inc"`
- Update `privacyEnabled` JSDoc to clarify it is a coarse signal (an email-only redaction can set it) and point callers to `redactedFields`/`privacyService` for precision
- Add `ContactField` union type and `redactedFields?: ContactField[]` to `Contact` so callers can see exactly which fields were dropped as placeholders or known-redacted, rather than just a boolean flag
- Add `privacyService?: boolean` to `Contact`, set by `finalizeContact` when the name/organization text identifies a privacy proxy service (value is kept; `redacted` is still set)
- Expand `finalizeContact` to clean all address/identity fields (not just email/phone/fax), record each dropped field in `redactedFields`, and accept `ContactField[]` as `redactedHint` so RFC 9537 field names can be passed through directly
- Add `redactionFields(text)` to map RFC 9537 redaction names (e.g. `"Registrant Email"`) to the corresponding `ContactField` entries; use it in `extractContacts` to narrow `redactedHint` to only fields that are actually absent
- Add `isPrivacyContact` helper and switch both RDAP and WHOIS normalizers to use it instead of inlining `isPrivacyName` checks
- Expand `PLACEHOLDER_VALUE_PATTERNS` in `privacy.ts` to cover `"Not available from registry"`, `"Not applicable"`, `"Data Protected"`, `"STATUTORY/GDPR Masking"`, `"Select Request Email Form"`, and `"Unknown"` (as an exact word)
- Export `resolveCountry`, `isPlaceholderValue`, and `isPrivacyName` from the package entry point
- Add `contacts.test.ts` covering placeholder-dropping, `redactedFields` population, `privacyService` flag, hint merging, `redactionFields` mapping, and the new placeholder patterns
- Move `parseVcard` out of `normalize.ts` into a new `src/rdap/vcard.ts` module; export `readJCard` (jCard → `VCardProp[]`) and `paramList` as reusable primitives
- Parse `KIND` into a typed `VCardKind` union and split structured `ORG` values into a top-level `org` plus an `orgUnits` array for sub-levels
- Parse `TITLE` and `ROLE` properties from the vCard
- Extract the PO box (element 0) and extended address (element 1) from `ADR` into `poBox` and the beginning of `street` respectively, rather than discarding them
- Strip `tel:` URI scheme from `TEL` entries whose `valueType` is `uri` (RFC 6350)
- Add `kind`, `organizationUnits`, `title`, `role`, and `poBox` to the `Contact` type and wire them through `extractContacts` in `normalize.ts`
- Document new `Contact` fields in README
- Add `vcard.test.ts` covering malformed input tolerance, `paramList` normalisation, ORG level splitting, unknown KIND, PO box extraction, and `tel:` URI stripping
- Add `scripts/generate-countries.mjs` to enumerate all current ISO 3166-1 alpha-2 codes via CLDR and emit `src/lib/countries-data.ts`; retired, exceptionally-reserved, and CLDR-only pseudo codes are explicitly excluded, XK (Kosovo) is kept
- Replace the live `Intl.DisplayNames` calls in `countries.ts` with a direct lookup against the generated `COUNTRY_NAMES` record, eliminating variance between Node/ICU versions
- Build the reverse `nameToCode` map from `COUNTRY_NAMES` entries instead of a double-nested loop over the alphabet
- Fix `countryCodeFromName` to fall through to `ALIASES` for two-letter inputs that aren't valid codes (e.g. `"UK"` → `"GB"`)
- Add `myanmar` / `"myanmar burma"` aliases so the CLDR-style name and the legacy WHOIS variant both resolve to `MM`
- Extend `countries.test.ts` to assert pinned names, XK presence, retired/reserved code exclusion, and `UK` alias behaviour
- Add `redacted?: boolean` to `Contact`; set when any field was dropped as a placeholder, the name matches a privacy-service heuristic, or an RFC 9537 redaction targets that entity's role
- Add `isPlaceholderValue` to `privacy.ts` to identify boilerplate registrar strings (`"Please query the RDDS…"`, `"redacted"`, `"n/a"`, etc.) used in place of real email/phone/fax values; drop them from the contact during finalization
- Add `countries.ts` backed by `Intl.DisplayNames` (with a curated `ALIASES` table for common alternate names) providing `countryNameFromCode`, `countryCodeFromName`, and `resolveCountry`; a two-letter `country` value is now treated as a code and expanded to a full name
- Introduce `finalizeContact` in `contacts.ts` to centralise all contact post-processing (placeholder stripping, country resolution, `redacted` flag); call it from both RDAP and WHOIS normalizers
- Wire RFC 9537 redaction role-matching into `extractContacts` via a new `redactionTargetsRole` helper so entities targeted by a `redacted` entry automatically receive `redacted: true`
- Add `countries.test.ts` and extend RDAP/WHOIS normalizer tests to cover placeholder stripping, country fill-in, and redaction flag propagation
- Split keyword list into `PRIVACY_STRONG_KEYWORDS` (matched as plain substrings) and two weaker sets (`PRIVACY_WEAK_WORDS` / `PRIVACY_CONTEXT_WORDS`) that only trigger when at least two distinct terms appear together, preventing names like "Private Equity Partners LLC" or "Protection One" from being misclassified
- Remove empty-placeholder values (`-`, `n/a`, `none`, etc.) from the privacy check; they are not privacy notices and should be handled separately by callers
- Add `privacy.test.ts` covering redaction notices, known privacy-service names, ordinary names with ambiguous words, and empty placeholders
- Update README comment to clarify that `privacyEnabled` is set by name heuristics (privacy-service and redaction phrases) or RFC 9537 redactions
- Parse the top-level RDAP `redacted` array (RFC 9537) into a new `redactions` field on `DomainRecord`; set `privacyEnabled` when any redaction references "registrant"
- Expose `street`, `city`, `state`, `postalCode`, `country`, and `countryCode` on both `RegistrarInfo` and `Contact`; extract ISO 3166-1 alpha-2 from the vCard `cc` parameter (RFC 8605)
- Split `tel` entries by fax type parameter (`"fax"` / `["work","fax"]`) into separate `tel` and `fax` lists; collect multiple email addresses as an array, collapsing singletons to a plain string
- Prefer an exact `registration` event match over `reregistration` when extracting `creationDate` (RFC 9083)
- Merge `redacted` arrays in `mergeRdapDocs`, deduplicating by JSON identity
- Split `adr` street on `\r?\n` only (not commas) to avoid splitting inline comma-separated addresses
- Parse `Retry-After` on RDAP `503` responses (previously only `429`), exposing `retryAfterMs` on the attempt and on the top-level result when `rdapOnly` makes it terminal
- Fix the WHOIS `BLOCK_PATTERNS` regex to use `[\s\S]` instead of `.` so a block notice that spans multiple lines (e.g. `"Your IP address\nhas been blocked"`) is still classified as `blocked`
- Update README to document `retryAfterMs` on `LookupResult`, clarify `rate_limited` / `blocked` / `unparseable` semantics, and note referral-host validation behaviour
- Parse `Retry-After` headers (delay-seconds or HTTP-date) into `retryAfterMs` via a new `parseRetryAfterMs` helper and expose the value on both `LookupAttempt` and `LookupResult` so callers can honour server-requested back-off without parsing the error string
- Propagate `retryAfterMs` through `classifyError`, `traced`, `failure`, and the `rdap_unavailable` path so the value surfaces on the top-level result
- Add `blocked` to `LookupErrorCode` for WHOIS servers that permanently refuse a client (distinct from `rate_limited`, which is a temporary throttle that may succeed on retry)
- Add `blockPrivateAddresses` to `WhoisTransportOptions`; when set, a custom `dns.lookup` shim rejects the connection before it opens if any resolved address is non-public, covering DNS rebinding and hostnames that resolve to private ranges
- Expand `isPrivateIp` / `isSafeWhoisReferralHost` to reject IPv4-mapped (`::ffff:7f00:1`), NAT64 (`64:ff9b::`), 6to4 (`2002:7f00::`), Teredo (`2001:0:`), deprecated site-local (`fec0::`), and bracketed IPv6 literals
- Add `isSafeWhoisReferralHost` to reject private/loopback/link-local IPs and malformed hostnames before following WHOIS referrals (SSRF guard)
- Detect WHOIS throttle replies in the referral chain: drop the registrar response, keep the registry record, and surface a warning instead of returning bad data
- Add `looksEmptyWhois` guard in the main lookup path: a "registered" record with no parseable fields now fails with `errorCode: "unparseable"` rather than resolving silently
- Map RDAP 429 responses to a structured `rate_limited` error (including `Retry-After` header) so callers can distinguish throttling from generic HTTP errors and the fallback to WHOIS is recorded in `attempts`
- Add `rate_limited` and `unparseable` to `LookupErrorCode`
- Change `collectWhoisReferralChain` to return `{ results, warnings }` instead of a bare array; warnings are merged onto the final `DomainRecord`
- Remove the `followWhoisReferrals` fallback path from `index.ts` (dead code after the chain API stabilised)
- Add `toISOFromTokens` to extract ISO dates from noisy values (e.g. ".ua" `0-UANIC 20111004161638`, `OK-UNTIL 20261004161638`)
- Parse compact `YYYYMMDDHHMMSS` (.ua) and `YYYYMMDD` (.br, including ticket suffix like `20260319 #31066859`) date formats
- Parse ordinal/prose dates used by .gg/.je (`28th December 2018 at 05:54:43.861`)
- Teach `parseKeyValueLines` to collect indented continuation lines for header-style blocks (.gg/.je) without mistaking URLs or times as key separators
- Extract .gg/.je expiry from `Relevant dates:` sentences and split inline registrar URL (`epag (http://www.epag.de)`)
- Map .ua `status: OK-UNTIL <timestamp>` to `expirationDate` when no explicit expiry field is present
- Treat RDAP `pending release` / `release process: waiting` statuses as `isRegistered: false` (previously always `true`)
- Mark WHOIS `.br` `release process: waiting` as available
- A server that accepts the connection and closes it without sending data now rejects with `errorCode: "no_data"` and `stage: "read"` instead of resolving with an empty string
- OS-level `ETIMEDOUT` errors are mapped to the same `timeout` / `stage` shape as the library's own timer, with `stage` derived from whether the socket had connected yet
- `ECONNRESET` after data arrives is no longer marked `partial: true`; it was a complete reply and the reset just signalled the end of the stream
- `stage` on `LookupAttempt` is now documented as covering all WHOIS failures (not only timeout failures)
- Resolve `LookupResult` with an `attempts` array describing every network operation (phase, server, duration, error) so failures recovered by fallback remain visible
- Add `deadlineMs` option for a hard cap on total lookup time, distinct from per-operation `timeoutMs` (lowered default from 15 s to 10 s)
- Add `errorCode` (machine-readable `LookupErrorCode`), `errorPhase`, and `errorServer` to `LookupResult`; `timeout` covers both per-op and deadline timeouts, `aborted` means the caller's signal fired
- WHOIS timeouts now distinguish `connect` vs `read` stage and resolve with partial text (marked `partial: true`) when data arrived before the socket stalled
- Drop Node 18 support; minimum engine is now 20
- Introduced `collectWhoisReferralChain` to gather WHOIS responses while avoiding contradictory data from registrars.
- Updated `lookupDomain` to utilize the new chain collection method, ensuring TLD responses are prioritized.
- Added `mergeWhoisRecords` function to consolidate WHOIS data from multiple sources.
- Enhanced tests for referral handling and merging behavior, ensuring accurate data retention across scenarios.