Enhance project structure: add AGENTS.md for repository guidelines, update .gitignore to exclude TypeScript build info, and refine package.json scripts for improved build and publish processes.

This commit is contained in:
2025-09-24 18:24:47 -04:00
parent 56e9e23e4b
commit 36cf6d6b38
26 changed files with 566 additions and 442 deletions
+129
View File
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeWhois } from "../normalize.js";
test("WHOIS .de (DENIC-like) nserver lines", () => {
const text = `
Domain: example.de
Nserver: ns1.example.net 192.0.2.1 2001:db8::1
Nserver: ns2.example.net
Status: connect
Changed: 2020-01-02
`;
const rec = normalizeWhois(
"example.de",
"de",
text,
"whois.denic.de",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
assert.equal(rec.nameservers?.[0].host, "ns1.example.net");
});
test("WHOIS .uk Nominet style", () => {
const text = `
Domain name:
example.uk
Data validation:
Nominet was able to match the registrant's name and address against a 3rd party data source on 01-Jan-2020
Registrar:
Registrar Ltd [Tag = REGTAG]
URL: https://registrar.example
Registered on: 01-Jan-2020
Expiry date: 01-Jan-2030
Last updated: 01-Jan-2021
Name servers:
ns1.example.net 192.0.2.1
ns2.example.net
`;
const rec = normalizeWhois(
"example.uk",
"uk",
text,
"whois.nic.uk",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
});
test("WHOIS .jp JPRS style privacy redacted", () => {
const text = `
[Domain Name] EXAMPLE.JP
[Registrant] (Not Disclosed)
[Name Server] ns1.example.jp
[Name Server] ns2.example.jp
[Created on] 2020/01/02
[Expires on] 2030/01/02
[Status] Active
`;
const rec = normalizeWhois(
"example.jp",
"jp",
text,
"whois.jprs.jp",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.ok(rec.statuses);
});
test("WHOIS .io NIC.IO style", () => {
const text = `
Domain Name: EXAMPLE.IO
Registry Domain ID: D000000000000-IONIC
Registrar WHOIS Server: whois.registrar.test
Registrar URL: http://www.registrar.test
Updated Date: 2021-01-02T03:04:05Z
Creation Date: 2020-01-02T03:04:05Z
Registry Expiry Date: 2030-01-02T03:04:05Z
Registrar: Registrar LLC
Name Server: NS1.EXAMPLE.IO
Name Server: NS2.EXAMPLE.IO
DNSSEC: unsigned
`;
const rec = normalizeWhois(
"example.io",
"io",
text,
"whois.nic.io",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.ok(rec.nameservers && rec.nameservers.length === 2);
});
test("Privacy redacted WHOIS normalizes without contacts", () => {
const text = `
Domain Name: EXAMPLE.COM
Registry Domain ID: 0000000000_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.registrar.test
Registrar URL: http://www.registrar.test
Updated Date: 2021-01-02T03:04:05Z
Creation Date: 2020-01-02T03:04:05Z
Registry Expiry Date: 2030-01-02T03:04:05Z
Registrar: Registrar LLC
Registrant Organization: Privacy Protect, LLC
Registrant State/Province: CA
Registrant Country: US
Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.
Name Server: NS1.EXAMPLE.COM
Name Server: NS2.EXAMPLE.COM
DNSSEC: unsigned
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
`;
const rec = normalizeWhois(
"example.com",
"com",
text,
"whois.verisign-grs.com",
"2025-01-01T00:00:00Z",
);
assert.ok(rec.creationDate);
assert.ok(rec.expirationDate);
assert.equal(rec.source, "whois");
});
+60
View File
@@ -0,0 +1,60 @@
// Centralized WHOIS data catalog.
// - tldExceptions: curated non-standard authoritative WHOIS servers by TLD
// - centralnicZones: known CentralNic-operated second-level public suffix zones
export const WHOIS_CATALOG = {
tldExceptions: {
// gTLDs
com: "whois.verisign-grs.com",
net: "whois.verisign-grs.com",
org: "whois.pir.org",
info: "whois.afilias.net",
biz: "whois.nic.biz",
edu: "whois.educause.edu",
gov: "whois.dotgov.gov",
// ccTLDs and others
de: "whois.denic.de",
jp: "whois.jprs.jp",
fr: "whois.nic.fr",
it: "whois.nic.it",
pl: "whois.dns.pl",
nl: "whois.domain-registry.nl",
be: "whois.dns.be",
se: "whois.iis.se",
no: "whois.norid.no",
fi: "whois.fi",
cz: "whois.nic.cz",
es: "whois.nic.es",
br: "whois.registro.br",
ca: "whois.cira.ca",
dk: "whois.dk-hostmaster.dk",
hk: "whois.hkirc.hk",
sg: "whois.sgnic.sg",
in: "whois.registry.in",
nz: "whois.srs.net.nz",
ch: "whois.nic.ch",
li: "whois.nic.li",
io: "whois.nic.io",
ai: "whois.nic.ai",
ru: "whois.tcinet.ru",
su: "whois.tcinet.ru",
"xn--p1ai": "whois.tcinet.ru", // .рф
// CentralNic-operated second-level zones (treat as exceptions here for simplicity)
"uk.com": "whois.centralnic.com",
"uk.net": "whois.centralnic.com",
"gb.com": "whois.centralnic.com",
"gb.net": "whois.centralnic.com",
"eu.com": "whois.centralnic.com",
"us.com": "whois.centralnic.com",
"se.com": "whois.centralnic.com",
"de.com": "whois.centralnic.com",
"br.com": "whois.centralnic.com",
"ru.com": "whois.centralnic.com",
"cn.com": "whois.centralnic.com",
"sa.com": "whois.centralnic.com",
} as Record<string, string>,
} as const;
export const WHOIS_TLD_EXCEPTIONS = WHOIS_CATALOG.tldExceptions;
+66
View File
@@ -0,0 +1,66 @@
import { createConnection } from "node:net";
import { DEFAULT_TIMEOUT_MS } from "../config.js";
import { withTimeout } from "../lib/async.js";
import type { LookupOptions } from "../types.js";
export interface WhoisQueryResult {
serverQueried: string;
text: string;
}
/**
* Perform a WHOIS query against an RFC 3912 server over TCP 43.
* Returns the raw text and the server used.
*/
export async function whoisQuery(
server: string,
query: string,
options?: LookupOptions,
): Promise<WhoisQueryResult> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const port = 43;
const host = server.replace(/^whois:\/\//i, "");
const text = await withTimeout(
queryTcp(host, port, query, options),
timeoutMs,
"WHOIS timeout",
);
return { serverQueried: server, text };
}
// Low-level WHOIS TCP client. Some registries require CRLF after the domain query.
function queryTcp(
host: string,
port: number,
query: string,
options?: LookupOptions,
): Promise<string> {
return new Promise((resolve, reject) => {
const socket = createConnection({ host, port });
let data = "";
let done = false;
const cleanup = () => {
if (done) return;
done = true;
socket.destroy();
};
socket.setTimeout((options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) - 1000, () => {
cleanup();
reject(new Error("WHOIS socket timeout"));
});
socket.on("error", (err) => {
cleanup();
reject(err);
});
socket.on("data", (chunk) => {
data += chunk.toString("utf8");
});
socket.on("end", () => {
cleanup();
resolve(data);
});
socket.on("connect", () => {
socket.write(`${query}\r\n`);
});
});
}
+56
View File
@@ -0,0 +1,56 @@
import type { LookupOptions } from "../types.js";
import { WHOIS_TLD_EXCEPTIONS } from "./catalog.js";
import { whoisQuery } from "./client.js";
/**
* Best-effort discovery of the authoritative WHOIS server for a TLD via IANA root DB.
*/
export async function ianaWhoisServerForTld(
tld: string,
options?: LookupOptions,
): Promise<string | undefined> {
const key = tld.toLowerCase();
// 1) Explicit hint override
const hint = options?.whoisHints?.[key];
if (hint) return normalizeServer(hint);
// 2) IANA WHOIS authoritative discovery over TCP 43
try {
const res = await whoisQuery("whois.iana.org", key, options);
const txt = res.text;
const m =
txt.match(/^whois:\s*(\S+)/im) ||
txt.match(/^refer:\s*(\S+)/im) ||
txt.match(/^whois server:\s*(\S+)/im);
const server = m?.[1];
if (server) return normalizeServer(server);
} catch {
// fallthrough to exceptions/guess
}
// 3) Curated exceptions
const exception = WHOIS_TLD_EXCEPTIONS[key];
if (exception) return normalizeServer(exception);
return undefined;
}
/**
* Extract registrar referral WHOIS server from a WHOIS response, if present.
*/
export function extractWhoisReferral(text: string): string | undefined {
const patterns = [
/^Registrar WHOIS Server:\s*(.+)$/im,
/^Whois Server:\s*(.+)$/im,
/^ReferralServer:\s*whois:\/\/(.+)$/im,
];
for (const re of patterns) {
const m = text.match(re);
if (m?.[1]) return m[1].trim();
}
return undefined;
}
function normalizeServer(server: string): string {
return server.replace(/^whois:\/\//i, "").replace(/\/$/, "");
}
+240
View File
@@ -0,0 +1,240 @@
import { toISO } from "../lib/dates.js";
import { parseKeyValueLines, uniq } from "../lib/text.js";
import type {
Contact,
DomainRecord,
Nameserver,
RegistrarInfo,
} from "../types.js";
/**
* Convert raw WHOIS text into our normalized DomainRecord.
* Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
*/
export function normalizeWhois(
domain: string,
tld: string,
whoisText: string,
whoisServer: string | undefined,
fetchedAtISO: string,
): DomainRecord {
const map = parseKeyValueLines(whoisText);
// Date extraction across common synonyms
const creationDate = anyValue(map, [
"creation date",
"created on",
"registered on",
"domain registration date",
"domain create date",
"created",
"registered",
]);
const updatedDate = anyValue(map, [
"updated date",
"last updated",
"last modified",
"modified",
]);
const expirationDate = anyValue(map, [
"registry expiry date",
"expiry date",
"expiration date",
"paid-till",
"expires on",
"renewal date",
]);
// Registrar info (thin registries like .com/.net require referral follow for full data)
const registrar: RegistrarInfo | undefined = (() => {
const name = anyValue(map, [
"registrar",
"sponsoring registrar",
"registrar name",
]);
const ianaId = anyValue(map, ["registrar iana id", "iana id"]);
const url = anyValue(map, [
"registrar url",
"url of the registrar",
"referrer",
]);
const abuseEmail = anyValue(map, [
"registrar abuse contact email",
"abuse contact email",
]);
const abusePhone = anyValue(map, [
"registrar abuse contact phone",
"abuse contact phone",
]);
if (!name && !ianaId && !url && !abuseEmail && !abusePhone)
return undefined;
return {
name: name || undefined,
ianaId: ianaId || undefined,
url: url || undefined,
email: abuseEmail || undefined,
phone: abusePhone || undefined,
};
})();
// Statuses: multiple entries are expected; keep raw
const statusLines = map["domain status"] || map.status || [];
const statuses = statusLines.length
? statusLines.map((line) => ({ status: line.split(/\s+/)[0], raw: line }))
: undefined;
// Nameservers: also appear as "nserver" on some ccTLDs (.de, .ru) and as "name server"
const nsLines: string[] = [
...(map["name server"] || []),
...(map.nameserver || []),
...(map["name servers"] || []),
...(map.nserver || []),
];
const nameservers: Nameserver[] | undefined = nsLines.length
? (uniq(
nsLines
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
// Common formats: "ns1.example.com" or "ns1.example.com 192.0.2.1" or "ns1.example.com 2001:db8::1"
const parts = line.split(/\s+/);
const host = parts.shift()?.toLowerCase() || "";
const ipv4: string[] = [];
const ipv6: string[] = [];
for (const p of parts) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(p)) ipv4.push(p);
else if (/^[0-9a-f:]+$/i.test(p)) ipv6.push(p);
}
if (!host) return undefined;
const ns: Nameserver = { host };
if (ipv4.length) ns.ipv4 = ipv4;
if (ipv6.length) ns.ipv6 = ipv6;
return ns;
})
.filter((x): x is Nameserver => !!x),
) as Nameserver[])
: undefined;
// Contacts: best-effort parse common keys
const contacts = collectContacts(map);
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
const dnssec = dnssecRaw
? { enabled: /signed|yes|true/.test(dnssecRaw) }
: undefined;
// Simple lock derivation from statuses
const transferLock = !!statuses?.some((s) =>
/transferprohibited/i.test(s.status),
);
const record: DomainRecord = {
domain,
tld,
isIDN: /(^|\.)xn--/i.test(domain),
unicodeName: undefined,
punycodeName: undefined,
registry: undefined,
registrar,
reseller: anyValue(map, ["reseller"]) || undefined,
statuses,
creationDate: toISO(creationDate || undefined),
updatedDate: toISO(updatedDate || undefined),
expirationDate: toISO(expirationDate || undefined),
deletionDate: undefined,
transferLock,
dnssec,
nameservers,
contacts,
whoisServer,
rdapServers: undefined,
rawRdap: undefined,
rawWhois: whoisText,
source: "whois",
fetchedAt: fetchedAtISO,
warnings: undefined,
};
return record;
}
function anyValue(
map: Record<string, string[]>,
keys: string[],
): string | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v[0];
}
return undefined;
}
function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
const roles: Array<{ role: Contact["type"]; prefix: string }> = [
{ role: "registrant", prefix: "registrant" },
{ role: "admin", prefix: "admin" },
{ role: "tech", prefix: "tech" },
{ role: "billing", prefix: "billing" },
{ role: "abuse", prefix: "abuse" },
];
const contacts: Contact[] = [];
for (const r of roles) {
const name = anyValue(map, [
`${r.prefix} name`,
`${r.prefix} contact name`,
`${r.prefix}`,
]);
const org = anyValue(map, [`${r.prefix} organization`, `${r.prefix} org`]);
const email = anyValue(map, [
`${r.prefix} email`,
`${r.prefix} contact email`,
`${r.prefix} e-mail`,
]);
const phone = anyValue(map, [
`${r.prefix} phone`,
`${r.prefix} contact phone`,
`${r.prefix} telephone`,
]);
const fax = anyValue(map, [`${r.prefix} fax`, `${r.prefix} facsimile`]);
const street = multi(map, [`${r.prefix} street`, `${r.prefix} address`]);
const city = anyValue(map, [`${r.prefix} city`]);
const state = anyValue(map, [
`${r.prefix} state`,
`${r.prefix} province`,
`${r.prefix} state/province`,
]);
const postalCode = anyValue(map, [
`${r.prefix} postal code`,
`${r.prefix} postcode`,
`${r.prefix} zip`,
]);
const country = anyValue(map, [`${r.prefix} country`]);
if (name || org || email || phone || street?.length) {
contacts.push({
type: r.role,
name: name || undefined,
organization: org || undefined,
email: email || undefined,
phone: phone || undefined,
fax: fax || undefined,
street: street,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
});
}
}
return contacts.length ? contacts : undefined;
}
function multi(
map: Record<string, string[]>,
keys: string[],
): string[] | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v;
}
return undefined;
}