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>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 15:19:03 -04:00
1 parent 2fc9f4dfc8
commit aef28393d7
2 files changed
+101 -22

No files matched your search

+40
View File
@@ -485,3 +485,43 @@ test("WHOIS availability phrases still mark a reply with no record data availabl
"domain: example.com.br\ncreated: 20100101\nnserver: ns1.example.com.br\nstatus: release process: waiting\n";
expect(normalizeWhois("example.com.br", "com.br", releasing, undefined).isRegistered).toBe(false);
});
test("WHOIS statuses come from the domain's block, whole and split on commas", () => {
const status = (text: string) =>
normalizeWhois("example.test", "test", text, undefined).statuses?.map((s) => s.status);
// .ua: contact blocks repeat "status:"
expect(status("domain: x.ua\nstatus: ok\n\ncontact: H1\nstatus: ok\nstatus: linked\n")).toEqual([
"ok",
]);
// .fr: EPP codes under "eppstatus", contacts repeat it
expect(
status(
"domain: x.fr\nstatus: ACTIVE\neppstatus: serverTransferProhibited\n\nnic-hdl: A1\neppstatus: active\n",
),
).toEqual(["serverTransferProhibited"]);
// .mx: "State" is a contact's region
expect(status("Domain Name: x.mx\n\nRegistrant:\n State: Nuevo Leon\n")).toBeUndefined();
// .ru: "state" beside the domain line
expect(status("domain: X.RU\nstate: REGISTERED, DELEGATED, VERIFIED\n")).toEqual([
"REGISTERED",
"DELEGATED",
"VERIFIED",
]);
// .sk comma list, .il phrase, .cz FRED wording
expect(
status("Domain: x.sk\nDomain Status: serverRenewProhibited, serverUpdateProhibited\n"),
).toEqual(["serverRenewProhibited", "serverUpdateProhibited"]);
expect(status("domain: x.il\nstatus: Transfer Locked\n")).toEqual(["Transfer Locked"]);
const cz = normalizeWhois(
"x.cz",
"cz",
"domain: x.cz\nstatus: Sponsoring registrar change forbidden\n",
undefined,
);
expect(cz.statuses?.map((s) => s.status)).toEqual(["serverTransferProhibited"]);
expect(cz.transferLock).toBe(true);
// .be: EPP statuses under a separate "Flags:" header
expect(
status("Domain: x.be\nStatus: NOT AVAILABLE\n\nFlags:\n\tclientTransferProhibited\n"),
).toEqual(["NOT AVAILABLE", "clientTransferProhibited"]);
});
+61 -22
View File
@@ -2,7 +2,7 @@ import { toISOFromTokens } from "../lib/dates";
import { finalizeContact, isPrivacyContact } from "../lib/contacts";
import { mergeNameservers } from "../lib/nameservers";
import { isEppStatus, normalizeEppStatus } from "../lib/status";
import { parseKeyValueBlocks, parseKeyValueLines } from "../lib/text";
import { parseKeyValueBlocks, parseKeyValueLines, uniqBy } from "../lib/text";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
// Phrases registries use to say a name is not registered. They also turn up in the remarks and
@@ -177,26 +177,19 @@ export function normalizeWhois(
};
})();
// Statuses: multiple entries are expected; keep raw
const statusLines =
map["domain status"] ||
map.status ||
map.flags ||
map.state || // .ru
map["registration status"] ||
map.eppstatus || // .fr
[];
// Report EPP codes, as RDAP does; a status may be spelled out ("client transfer prohibited")
// Statuses come from the first block that lists any, as contact blocks repeat "status:" (.ua,
// .fr). .be lists its EPP statuses under a separate "Flags:" header.
const statusLines = [
...(statusLinesOf(blocks) ?? statusLinesOf([map], false) ?? []),
...blocks.flatMap((b) => b.flags ?? []).filter((f) => isEppStatus(f)),
];
const statuses = statusLines.length
? statusLines
.map((line) => {
const phrase = line.replace(/\s*(?:\(?https?:\/\/\S*\)?|\(.*\)).*$/, "");
const status = normalizeEppStatus(
isEppStatus(phrase) ? phrase : (line.split(/\s+/)[0] ?? ""),
);
return status ? { status, raw: line } : null;
})
.filter((s): s is { status: string; raw: string } => s !== null)
? uniqBy(
statusLines.flatMap((line) =>
parseStatusLine(line).map((status) => ({ status, raw: line })),
),
(s) => s.status.toLowerCase(),
)
: undefined;
// Some registries (.ua) publish expiry only as a status, e.g. "OK-UNTIL 20261004161638"
@@ -257,9 +250,9 @@ export function normalizeWhois(
? { enabled: dnssecValues.some((v) => /^(?:signed|yes|true|active)/i.test(v.trim())) }
: undefined;
// Simple lock derivation from statuses
// Simple lock derivation from statuses (the EPP code, or the registry's wording in raw)
const transferLock = !!statuses?.some((s) =>
/transfer[-\s]*prohibited/i.test(s.raw || s.status || ""),
/transfer[-\s]*prohibited/i.test(`${s.status} ${s.raw ?? ""}`),
);
const record: DomainRecord = {
@@ -296,6 +289,52 @@ export function normalizeWhois(
return record;
}
// Status keys by preference: .fr lists EPP codes as "eppstatus" beside a plain "status: ACTIVE"
const STATUS_KEYS = ["eppstatus", "domain status", "status", "registration status"];
/**
* Status lines of the first block that has any. "state" is .ru's status key but a contact's
* region elsewhere ("State: Nuevo Leon" on .mx), so it only counts beside the domain line.
*/
function statusLinesOf(
blocks: Array<Record<string, string[]>>,
allowState = true,
): string[] | undefined {
for (const block of blocks) {
const key = STATUS_KEYS.find((k) => block[k]?.length);
if (key) return block[key];
if (allowState && block.state?.length && (block.domain || block["domain name"])) {
return block.state;
}
}
return undefined;
}
// FRED registries (.cz, .mk, ...) describe server statuses in words
const FRED_STATUSES: Record<string, string> = {
"sponsoring registrar change forbidden": "serverTransferProhibited",
"deletion forbidden": "serverDeleteProhibited",
"update forbidden": "serverUpdateProhibited",
};
/**
* Statuses in one WHOIS status line, as EPP codes where they have one, otherwise as the
* registry's own phrase: "clientTransferProhibited https://icann.org/epp#...", "ok (paid and in
* zone)", "REGISTERED, DELEGATED, VERIFIED", "Transfer Locked", "OK-UNTIL 20261004161638".
*/
function parseStatusLine(line: string): string[] {
const phrase = line.replace(/\s*(?:\(?https?:\/\/\S*\)?|\(.*\)).*$/, "");
return phrase
.split(",")
.map((part) => {
const p = part.trim().replace(/\.$/, "");
if (isEppStatus(p)) return normalizeEppStatus(p);
// A trailing date explains the status rather than naming it (.ua "OK-UNTIL <date>")
return FRED_STATUSES[p.toLowerCase()] ?? p.replace(/(?:\s+\d\S*)+$/, "");
})
.filter(Boolean);
}
/** First value that parses as a date, taking blocks in order and keys by priority within each. */
function firstDate(blocks: Array<Record<string, string[]>>, keys: string[]): string | undefined {
for (const block of blocks) {