100 Commits
Author SHA1 Message Date
jake 211978398b 0.18.0 2026-10-04 13:22:52 -04:00
jake 3acf6d5ee8 fix: make toRegistrableDomain/getDomainTld sub-registry-aware, strip registry handles from contacts, and restrict RDAP link fetches to public URLs
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.
2026-10-04 13:22:01 -04:00
jake ea1c265b36 0.17.0 2026-10-03 16:49:36 -04:00
jakeandClaude Opus 5.5 835a7f63b7 fix: reject a WHOIS reply that describes the domain the name sits under
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>
2026-10-03 16:46:49 -04:00
jakeandClaude Opus 5.5 6710a2878f fix: stop reporting nhs.uk and police.uk names as unregistered
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>
2026-10-03 16:45:57 -04:00
jakeandClaude Opus 5.5 a14f8a245b fix: look up ac.uk names at Jisc instead of Nominet
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>
2026-10-03 16:45:15 -04:00
jake 708303e60b fix: reject subdomains, surface rdapOnly error codes, and tighten isPrivacyContact
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.
2026-10-03 16:17:29 -04:00
jakeandClaude Opus 5.5 79a2abbbbd feat: reject isAvailable/isRegistered failures with a coded RdapperError
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>
2026-10-03 15:46:59 -04:00
jakeandClaude Opus 5.5 1cd15eb85c fix: reject rdapOnly together with whoisOnly
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>
2026-10-03 15:46:30 -04:00
jakeandClaude Opus 5.5 fb7e279a27 fix: prefer the registry's dates when merging WHOIS referrals
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>
2026-10-03 15:38:06 -04:00
jakeandClaude Opus 5.5 7e2b2473e8 fix: make the CLI look up every argument and report failures
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>
2026-10-03 15:36:21 -04:00
jakeandClaude Opus 5.5 7ee68be7cc fix: recognize CNNIC and DENIC throttle replies
"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>
2026-10-03 15:35:45 -04:00
jakeandClaude Opus 5.5 9e0fc2be80 fix: classify undici timeouts and socket errors
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>
2026-10-03 15:35:42 -04:00
jakeandClaude Opus 5.5 24a977244d fix: honor allowPrivateDomains in toRegistrableDomain
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>
2026-10-03 15:35:09 -04:00
jakeandClaude Opus 5.5 7a39bc3bfa fix: fall back to WHOIS when a bootstrap fetch aborts on its own
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>
2026-10-03 15:34:51 -04:00
jakeandClaude Opus 5.5 3b97803720 fix: don't misreport an unreadable RDAP response as a missing server
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>
2026-10-03 15:34:18 -04:00
jakeandClaude Opus 5.5 4ffb59370c fix: follow RDAP links breadth-first within maxRdapLinkHops fetches
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>
2026-10-03 15:33:23 -04:00
jakeandClaude Opus 5.5 b723c39f23 fix: pin month-year dates to UTC and refuse qualified dates
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>
2026-10-03 15:31:45 -04:00
jakeandClaude Opus 5.5 652be4b819 fix: find the WHOIS server for multi-label suffixes through their TLD
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>
2026-10-03 15:31:45 -04:00
jakeandClaude Opus 5.5 4021bf5764 fix: don't take registry handles or notices as WHOIS contact names
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>
2026-10-03 15:28:53 -04:00
jakeandClaude Opus 5.5 c85e5cd9cb fix: fill the RDAP registrar from every registrar entity
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>
2026-10-03 15:27:57 -04:00
jakeandClaude Opus 5.5 3071c4867a fix: combine registry and registrar copies of a contact
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>
2026-10-03 15:27:12 -04:00
jakeandClaude Opus 5.5 7b6db04ca1 fix: keep multi-line vCard street addresses
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>
2026-10-03 15:25:49 -04:00
jakeandClaude Opus 5.5 6e00874c8d fix: decode Latin-1 WHOIS replies
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>
2026-10-03 15:24:59 -04:00
jakeandClaude Opus 5.5 f8e6448094 fix: read .lu's registrar and drop the generic "organisation" key
.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>
2026-10-03 15:24:20 -04:00
jakeandClaude Opus 5.5 6e96e49c28 fix: read .it DNSSEC from the domain, not the registrar section
.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>
2026-10-03 15:24:00 -04:00
jakeandClaude Opus 5.5 81a02a7eb3 fix: read more WHOIS date keys and reject vague dates
- 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>
2026-10-03 15:23:30 -04:00
jakeandClaude Opus 5.5 7451b41cf0 fix: read WHOIS nameservers and their glue across ccTLD layouts
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>
2026-10-03 15:22:22 -04:00
jakeandClaude Opus 5.5 26596b3b2a fix: read WHOIS section headers and their nested fields
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>
2026-10-03 15:20:59 -04:00
jakeandClaude Opus 5.5 aef28393d7 fix: take WHOIS statuses from the domain's block, whole
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>
2026-10-03 15:19:03 -04:00
jakeandClaude Opus 5.5 2fc9f4dfc8 fix: stop flagging ordinary company names as privacy services
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>
2026-10-03 15:16:53 -04:00
jakeandClaude Opus 5.5 bca20bc5f0 fix: don't mark registered WHOIS records available over a remark
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>
2026-10-03 15:15:52 -04:00
jakeandClaude Opus 5.5 58f5ef2277 fix: lowercase the RDAP ldhName and don't use the handle as the name
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>
2026-10-03 15:14:37 -04:00
jakeandClaude Opus 5.5 217876263c fix: merge related RDAP documents only when they describe the queried domain
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>
2026-10-03 15:14:14 -04:00
jakeandClaude Opus 5.5 9b63505181 fix: normalize lookup input before querying
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>
2026-10-03 15:13:04 -04:00
jakeandClaude Opus 5.5 5e0da531f6 fix: dedupe nameservers by host
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>
2026-10-03 15:07:47 -04:00
jakeandClaude Opus 5.5 49f6ee3007 fix: read WHOIS keys padded with dots
.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>
2026-10-03 15:06:12 -04:00
jakeandClaude Opus 5.5 54f4dcbc64 fix: don't report DNSSEC as enabled for unsigned WHOIS records
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>
2026-10-03 15:05:11 -04:00
jakeandClaude Opus 5.5 fd8a63962d fix: set reseller from RDAP reseller entities
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>
2026-10-03 14:58:13 -04:00
jakeandClaude Opus 5.5 a5e81696b6 fix: report EPP status codes from RDAP and WHOIS alike
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>
2026-10-03 14:56:23 -04:00
jakeandClaude Opus 5.5 b7539fe683 fix: read the .tw registrar from "Registration Service Provider"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:53:56 -04:00
jakeandClaude Opus 5.5 f654f1b3f7 fix: take WHOIS dates from the domain's own block
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>
2026-10-03 14:53:12 -04:00
jakeandClaude Opus 5.5 19a0121b3f fix: don't treat WHOIS records with a rate-limit footer as refusals
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>
2026-10-03 14:46:37 -04:00
jakeandClaude Opus 5.5 d1f1bd4bd8 fix: route CentralNic sub-registry names to CentralNic
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>
2026-10-03 14:45:49 -04:00
jakeandClaude Opus 5.5 9fe583a97a fix: parse WHOIS dates independently of the host timezone
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>
2026-10-03 14:41:10 -04:00
jakeandClaude Opus 5.5 525ef4b7b1 fix: read the registrar abuse contact nested in RDAP registrar entities
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>
2026-10-03 14:36:58 -04:00
jakeandClaude Opus 5.5 8f601194fa fix: follow WHOIS referrals in indented replies
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>
2026-10-03 14:35:50 -04:00
jake c81a51df09 0.16.4 2026-09-20 17:50:14 -04:00
jake 03d9aa3775 0.16.3 2026-09-20 17:48:21 -04:00
jake 026a93c69f chore: simplify package exports, enable tsdown exports, and upgrade to TypeScript 7
- 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`
2026-09-20 17:47:36 -04:00
jake 3ded70f364 0.16.2 2026-09-20 11:11:07 -04:00
jake 777de1f63f test: add coverage for whole-value placeholder matching, country redactionFields, and idempotent re-finalization
- 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
2026-09-20 11:10:59 -04:00
jake 1ab34f4561 feat: add country to ContactField, drop placeholder countries, and make finalizeContact idempotent
- 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
2026-09-20 11:04:47 -04:00
jake 0fea5e0a05 0.16.1 2026-09-20 00:14:13 -04:00
jake 194ce2bfd7 fix: fall back to vCard prePath in redactionFields and consolidate privacy phrase lists
- 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
2026-09-20 00:13:44 -04:00
jake adac9d3349 feat: add redactedFields, privacyService, and ContactField to contact post-processing
- 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
2026-09-20 00:04:25 -04:00
jake 7ab25ceecc 0.16.0 2026-09-19 23:24:12 -04:00
jake 992c54ad28 refactor: extract vCard parser into its own module and expand parsed fields
- 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
2026-09-19 23:20:15 -04:00
jake 3595e5b42c refactor: replace runtime Intl.DisplayNames with a static CLDR-generated country table
- 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
2026-09-19 23:14:43 -04:00
jake 89fa13ffef feat: add contact redacted flag, placeholder detection, and country name/code resolution
- 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
2026-09-19 23:07:38 -04:00
jake 1d04be5c74 fix: tighten isPrivacyName to avoid false positives on ambiguous privacy words
- 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
2026-09-19 23:04:01 -04:00
jake 386006dc61 feat: add RFC 9537 redaction support, vCard address/fax parsing, and registrar address fields
- 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
2026-09-19 23:01:30 -04:00
jake e77fc7f4ed 0.15.1 2026-09-19 12:32:07 -04:00
jake 93f877c357 fix: extend Retry-After parsing to RDAP 503 and fix block pattern across newlines
- 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
2026-09-19 12:30:06 -04:00
jake 35959b208b feat: surface retryAfterMs, add blocked error code, and harden private-address guard
- 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
2026-09-19 12:25:30 -04:00
jake 208fd6c69f 0.15.0 2026-09-19 12:17:47 -04:00
jake 3023c5dd1b feat: add SSRF host validation, rate_limited/unparseable error codes, and referral chain warnings
- 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)
2026-09-19 12:14:19 -04:00
jake 61c5897317 0.14.0 2026-09-19 00:56:46 -04:00
jake 56051dccd0 chore: fix lockfile 2026-09-19 00:56:30 -04:00
jake be8e88592f feat: add ccTLD-specific date formats, noisy-token parsing, and .gg/.je/.br/.ua normalization, closes #20 and #25
- 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
2026-09-19 00:51:55 -04:00
jake e736045de1 fix: treat WHOIS empty-close as no_data and ETIMEDOUT as structured timeout
- 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)
2026-09-18 14:47:31 -04:00
jake 69f85ee925 feat: add attempt tracking, deadlineMs, and structured error codes
- 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
2026-09-18 14:41:16 -04:00
jake 87e20f4a47 chore: replace Biome with oxlint and oxfmt 2026-09-18 14:21:15 -04:00
jake 5ff837b2bb chore(deps): update dependencies and build configuration 2026-09-18 14:10:09 -04:00
jake a37ab8a6d8 0.13.0 2026-01-10 13:35:11 -05:00
jake 0f64809bb1 chore: add GitHub Actions workflow for testing across multiple Node.js versions 2026-01-10 13:34:27 -05:00
jake 6ef4291d0e 0.12.1 2025-12-30 12:53:25 -05:00
jake e6b2e94614 fix: update tsdown outputs from .js to .mjs 2025-12-30 12:53:17 -05:00
jake c6da4c1f69 0.12.0 2025-12-30 12:45:00 -05:00
jake ab1326d99c fix: improve transfer lock status detection 2025-12-30 12:43:55 -05:00
jake 1b73b7dc8d chore: bump deps 2025-12-30 12:41:13 -05:00
jake b72324fd95 0.11.0 2025-10-30 19:07:47 -04:00
jake 619185b5d6 Add support for custom fetch function and/or IANA bootstrap data (#21) 2025-10-30 19:07:12 -04:00
jake 8c8dec5a37 0.10.4 2025-10-27 15:01:07 -04:00
jake 2a92375116 Add support for DD-MM-YYYY date format in toISO function and update tests (fixes #19) 2025-10-27 14:59:14 -04:00
jake fd29899f4e 0.10.3 2025-10-27 14:44:25 -04:00
jake 9d87ef8ecb Add to list of parser strings to improve WHOIS data normalization 2025-10-27 14:42:43 -04:00
jake 1c6fbad1f7 0.10.2 2025-10-22 17:45:40 -04:00
jake 87b85f7d3d Add query transformation for non-English WHOIS servers, specifically for whois.jprs.jp (fixes #15) 2025-10-22 17:45:19 -04:00
jake c3b6477094 0.10.1 2025-10-22 17:38:36 -04:00
jake b8357e38bc Fix .hk, .ee, .il date parsing (fixes #13, #14, #16) 2025-10-22 17:37:37 -04:00
jake f90c481c94 0.10.0 2025-10-20 15:06:29 -04:00
jake b3adbf8307 Rename lookupDomain() to lookup() (with backwards compatibility) 2025-10-20 15:05:59 -04:00
jake 5cbefe6a14 Update README.md with clearer usage instructions 2025-10-20 14:48:16 -04:00
jake 52e5c54f9f 0.9.1 2025-10-20 14:30:48 -04:00
jake b9d4d42e19 Update GitHub Actions workflow to install the latest npm version before publishing 2025-10-20 14:24:35 -04:00
jake 2f0e784cf7 Run npm pkg fix 2025-10-20 14:20:26 -04:00
jake d87e898a9f Add additional synonyms for WHOIS normalization (fixes #10) 2025-10-20 14:15:23 -04:00
jake 2a1b7529cc Implement WHOIS referral chain collection and merging logic (fixes #11)
- 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.
2025-10-20 14:08:26 -04:00
jake b00d0bbb0a Fix order of package.json exports 2025-10-20 14:05:33 -04:00