157 Commits
Author SHA1 Message Date
jake 211978398b 0.18.0 v0.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 v0.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 v0.16.4 2026-09-20 17:50:14 -04:00
jake 03d9aa3775 0.16.3 v0.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