mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25:31 -04:00
chore: replace Biome with oxlint and oxfmt
This commit is contained in:
@@ -15,9 +15,7 @@ maybeTest("lookup smoke test (example.com)", async () => {
|
||||
expect(res.ok, res.error).toBe(true);
|
||||
expect(Boolean(res.record?.domain)).toBe(true);
|
||||
expect(Boolean(res.record?.tld)).toBe(true);
|
||||
expect(res.record?.source === "rdap" || res.record?.source === "whois").toBe(
|
||||
true,
|
||||
);
|
||||
expect(res.record?.source === "rdap" || res.record?.source === "whois").toBe(true);
|
||||
});
|
||||
|
||||
// RDAP-only smoke for reserved example domains (.com/.net/.org)
|
||||
@@ -42,9 +40,7 @@ for (const c of rdapCases) {
|
||||
if (c.tld !== "org") {
|
||||
// .com/.net often include the IANA reserved name explicitly
|
||||
expect(
|
||||
(rec.registrar?.name || "")
|
||||
.toLowerCase()
|
||||
.includes("internet assigned numbers authority"),
|
||||
(rec.registrar?.name || "").toLowerCase().includes("internet assigned numbers authority"),
|
||||
).toBe(true);
|
||||
}
|
||||
// IANA nameservers
|
||||
@@ -112,9 +108,7 @@ maybeTest("WHOIS-only lookup for example.io", async () => {
|
||||
});
|
||||
|
||||
maybeTest("isRegistered true for example.com", async () => {
|
||||
await expect(isRegistered("example.com", { timeoutMs: 15000 })).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(isRegistered("example.com", { timeoutMs: 15000 })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
maybeTest("isAvailable true for an unlikely .com", async () => {
|
||||
|
||||
+9
-21
@@ -30,18 +30,13 @@ vi.mock("./whois/referral.js", async () => {
|
||||
const client = await import("./whois/client.js");
|
||||
return {
|
||||
followWhoisReferrals: vi.fn(
|
||||
async (
|
||||
server: string,
|
||||
domain: string,
|
||||
opts?: import("./types").LookupOptions,
|
||||
) => client.whoisQuery(server, domain, opts),
|
||||
async (server: string, domain: string, opts?: import("./types").LookupOptions) =>
|
||||
client.whoisQuery(server, domain, opts),
|
||||
),
|
||||
collectWhoisReferralChain: vi.fn(
|
||||
async (
|
||||
server: string,
|
||||
domain: string,
|
||||
opts?: import("./types").LookupOptions,
|
||||
) => [await client.whoisQuery(server, domain, opts)],
|
||||
async (server: string, domain: string, opts?: import("./types").LookupOptions) => [
|
||||
await client.whoisQuery(server, domain, opts),
|
||||
],
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -55,8 +50,7 @@ vi.mock("./whois/discovery.js", async () => {
|
||||
});
|
||||
|
||||
vi.mock("./lib/domain.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./lib/domain.js")>("./lib/domain.js");
|
||||
const actual = await vi.importActual<typeof import("./lib/domain.js")>("./lib/domain.js");
|
||||
return {
|
||||
...actual,
|
||||
// Default to actual behavior; specific tests can override
|
||||
@@ -75,9 +69,7 @@ import * as whoisReferral from "./whois/referral";
|
||||
describe("lookup orchestration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue(
|
||||
"whois.verisign-grs.com",
|
||||
);
|
||||
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com");
|
||||
});
|
||||
|
||||
it("uses RDAP when available and does not call WHOIS", async () => {
|
||||
@@ -89,9 +81,7 @@ describe("lookup orchestration", () => {
|
||||
});
|
||||
|
||||
it("falls back to WHOIS when RDAP fails", async () => {
|
||||
vi.mocked(rdapClient.fetchRdapDomain).mockRejectedValueOnce(
|
||||
new Error("rdap down"),
|
||||
);
|
||||
vi.mocked(rdapClient.fetchRdapDomain).mockRejectedValueOnce(new Error("rdap down"));
|
||||
const res = await lookup("example.com", { timeoutMs: 200 });
|
||||
expect(res.ok, res.error).toBe(true);
|
||||
expect(res.record?.source).toBe("whois");
|
||||
@@ -155,9 +145,7 @@ describe("RDAP 404 handling", () => {
|
||||
describe("WHOIS referral & includeRaw", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue(
|
||||
"whois.verisign-grs.com",
|
||||
);
|
||||
vi.mocked(discovery.ianaWhoisServerForTld).mockResolvedValue("whois.verisign-grs.com");
|
||||
});
|
||||
|
||||
it("does not follow referral when followWhoisReferral is false", async () => {
|
||||
|
||||
+7
-30
@@ -11,19 +11,13 @@ import {
|
||||
} from "./whois/discovery";
|
||||
import { mergeWhoisRecords } from "./whois/merge";
|
||||
import { normalizeWhois } from "./whois/normalize";
|
||||
import {
|
||||
collectWhoisReferralChain,
|
||||
followWhoisReferrals,
|
||||
} from "./whois/referral";
|
||||
import { collectWhoisReferralChain, followWhoisReferrals } from "./whois/referral";
|
||||
|
||||
/**
|
||||
* High-level lookup that prefers RDAP and falls back to WHOIS.
|
||||
* Ensures a standardized DomainRecord, independent of the source.
|
||||
*/
|
||||
export async function lookup(
|
||||
domain: string,
|
||||
opts?: LookupOptions,
|
||||
): Promise<LookupResult> {
|
||||
export async function lookup(domain: string, opts?: LookupOptions): Promise<LookupResult> {
|
||||
try {
|
||||
if (!isLikelyDomain(domain)) {
|
||||
return { ok: false, error: "Input does not look like a domain" };
|
||||
@@ -61,11 +55,7 @@ export async function lookup(
|
||||
return { ok: true, record };
|
||||
}
|
||||
|
||||
const rdapEnriched = await fetchAndMergeRdapRelated(
|
||||
domain,
|
||||
json,
|
||||
opts,
|
||||
);
|
||||
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
|
||||
const record: DomainRecord = normalizeRdap(
|
||||
domain,
|
||||
tld,
|
||||
@@ -92,9 +82,7 @@ export async function lookup(
|
||||
if (!whoisServer) {
|
||||
// Provide a clearer, actionable message
|
||||
const ianaText = await getIanaWhoisTextForTld(tld, opts);
|
||||
const regUrl = ianaText
|
||||
? parseIanaRegistrationInfoUrl(ianaText)
|
||||
: undefined;
|
||||
const regUrl = ianaText ? parseIanaRegistrationInfoUrl(ianaText) : undefined;
|
||||
const hint = regUrl ? ` See registration info at ${regUrl}.` : "";
|
||||
return {
|
||||
ok: false,
|
||||
@@ -138,10 +126,7 @@ export async function lookup(
|
||||
* Determine if a domain appears available (not registered).
|
||||
* Performs a lookup and resolves to a boolean. Rejects on lookup error.
|
||||
*/
|
||||
export async function isAvailable(
|
||||
domain: string,
|
||||
opts?: LookupOptions,
|
||||
): Promise<boolean> {
|
||||
export async function isAvailable(domain: string, opts?: LookupOptions): Promise<boolean> {
|
||||
const res = await lookup(domain, opts);
|
||||
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
||||
return res.record.isRegistered === false;
|
||||
@@ -151,10 +136,7 @@ export async function isAvailable(
|
||||
* Determine if a domain appears registered.
|
||||
* Performs a lookup and resolves to a boolean. Rejects on lookup error.
|
||||
*/
|
||||
export async function isRegistered(
|
||||
domain: string,
|
||||
opts?: LookupOptions,
|
||||
): Promise<boolean> {
|
||||
export async function isRegistered(domain: string, opts?: LookupOptions): Promise<boolean> {
|
||||
const res = await lookup(domain, opts);
|
||||
if (!res.ok || !res.record) throw new Error(res.error || "Lookup failed");
|
||||
return res.record.isRegistered === true;
|
||||
@@ -165,10 +147,5 @@ export async function isRegistered(
|
||||
*/
|
||||
export const lookupDomain = lookup;
|
||||
|
||||
export {
|
||||
getDomainParts,
|
||||
getDomainTld,
|
||||
isLikelyDomain,
|
||||
toRegistrableDomain,
|
||||
} from "./lib/domain";
|
||||
export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain";
|
||||
export type * from "./types";
|
||||
|
||||
+3
-15
@@ -1,8 +1,6 @@
|
||||
// Lightweight date parsing helpers to avoid external dependencies.
|
||||
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
|
||||
export function toISO(
|
||||
dateLike: string | number | Date | undefined | null,
|
||||
): string | undefined {
|
||||
export function toISO(dateLike: string | number | Date | undefined | null): string | undefined {
|
||||
if (dateLike == null) return undefined;
|
||||
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
|
||||
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
|
||||
@@ -53,10 +51,7 @@ function toIsoFromDate(d: Date): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDateWithRegex(
|
||||
m: RegExpMatchArray,
|
||||
_re: RegExp,
|
||||
): Date | undefined {
|
||||
function parseDateWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
|
||||
const monthMap: Record<string, number> = {
|
||||
jan: 0,
|
||||
feb: 1,
|
||||
@@ -77,14 +72,7 @@ function parseDateWithRegex(
|
||||
const [_, y, mo, d, hh, mm, ss, offH, offM] = m;
|
||||
if (!y || !mo || !d || !hh || !mm || !ss) return undefined;
|
||||
// Base time as UTC
|
||||
let dt = Date.UTC(
|
||||
Number(y),
|
||||
Number(mo) - 1,
|
||||
Number(d),
|
||||
Number(hh),
|
||||
Number(mm),
|
||||
Number(ss),
|
||||
);
|
||||
let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
||||
// Apply timezone offset if present (e.g., +0000, -0500, +05:30)
|
||||
if (offH) {
|
||||
const sign = offH.startsWith("-") ? -1 : 1;
|
||||
|
||||
@@ -14,15 +14,11 @@ test("isLikelyDomain", () => {
|
||||
test("toRegistrableDomain normalizes eTLD+1 and rejects non-ICANN", () => {
|
||||
// Basic domains
|
||||
expect(toRegistrableDomain("example.com")).toBe("example.com");
|
||||
expect(toRegistrableDomain("http://www.writethedocs.org/conf")).toBe(
|
||||
"writethedocs.org",
|
||||
);
|
||||
expect(toRegistrableDomain("http://www.writethedocs.org/conf")).toBe("writethedocs.org");
|
||||
|
||||
// Private/public SLDs should collapse to ICANN TLD + SLD by default
|
||||
// (ICANN-only behavior; private suffixes ignored)
|
||||
expect(toRegistrableDomain("spark-public.s3.amazonaws.com")).toBe(
|
||||
"amazonaws.com",
|
||||
);
|
||||
expect(toRegistrableDomain("spark-public.s3.amazonaws.com")).toBe("amazonaws.com");
|
||||
|
||||
// Reject IPs and invalid inputs
|
||||
expect(toRegistrableDomain("192.168.0.1")).toBeNull();
|
||||
|
||||
+3
-12
@@ -6,10 +6,7 @@ type ParseOptions = Parameters<typeof parse>[1];
|
||||
* Parse a domain into its parts. Passes options to `tldts.parse()`.
|
||||
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
|
||||
*/
|
||||
export function getDomainParts(
|
||||
domain: string,
|
||||
opts?: ParseOptions,
|
||||
): ReturnType<typeof parse> {
|
||||
export function getDomainParts(domain: string, opts?: ParseOptions): ReturnType<typeof parse> {
|
||||
return parse(domain, { ...opts });
|
||||
}
|
||||
|
||||
@@ -17,10 +14,7 @@ export function getDomainParts(
|
||||
* Get the TLD (ICANN-only public suffix) of a domain. Passes options to `tldts.parse()`.
|
||||
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
|
||||
*/
|
||||
export function getDomainTld(
|
||||
domain: string,
|
||||
opts?: ParseOptions,
|
||||
): string | null {
|
||||
export function getDomainTld(domain: string, opts?: ParseOptions): string | null {
|
||||
const result = getDomainParts(domain, {
|
||||
allowPrivateDomains: false,
|
||||
...opts,
|
||||
@@ -54,10 +48,7 @@ export function punyToUnicode(domain: string): string {
|
||||
* Returns null when the input is not a valid ICANN domain (e.g., invalid TLD, IPs)
|
||||
* @see https://github.com/remusao/tldts/blob/master/packages/tldts-core/src/options.ts
|
||||
*/
|
||||
export function toRegistrableDomain(
|
||||
input: string,
|
||||
opts?: ParseOptions,
|
||||
): string | null {
|
||||
export function toRegistrableDomain(input: string, opts?: ParseOptions): string | null {
|
||||
const raw = (input ?? "").trim();
|
||||
if (raw === "") return null;
|
||||
|
||||
|
||||
+1
-6
@@ -69,11 +69,6 @@ export function asStringArray(value: unknown): string[] | undefined {
|
||||
}
|
||||
|
||||
export function asDateLike(value: unknown): string | number | Date | undefined {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
value instanceof Date
|
||||
)
|
||||
return value;
|
||||
if (typeof value === "string" || typeof value === "number" || value instanceof Date) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BootstrapData } from "../types";
|
||||
import { getRdapBaseUrlsForTld } from "./bootstrap";
|
||||
|
||||
@@ -110,9 +102,7 @@ describe("getRdapBaseUrlsForTld with customBootstrapData", () => {
|
||||
const dataWithDuplicates: BootstrapData = {
|
||||
version: "1.0",
|
||||
publication: "2025-01-15T12:00:00Z",
|
||||
services: [
|
||||
[["test"], ["https://rdap.example.com/", "https://rdap.example.com"]],
|
||||
],
|
||||
services: [[["test"], ["https://rdap.example.com/", "https://rdap.example.com"]]],
|
||||
};
|
||||
|
||||
const urls = await getRdapBaseUrlsForTld("test", {
|
||||
|
||||
+1
-4
@@ -24,10 +24,7 @@ export async function fetchRdapDomain(
|
||||
baseUrl: string,
|
||||
options?: LookupOptions,
|
||||
): Promise<RdapFetchResult> {
|
||||
const url = new URL(
|
||||
`domain/${encodeURIComponent(domain)}`,
|
||||
baseUrl,
|
||||
).toString();
|
||||
const url = new URL(`domain/${encodeURIComponent(domain)}`, baseUrl).toString();
|
||||
const fetchFn = resolveFetch(options);
|
||||
const res = await withTimeout(
|
||||
fetchFn(url, {
|
||||
|
||||
+1
-3
@@ -13,9 +13,7 @@ export function extractRdapRelatedLinks(
|
||||
opts?: Pick<LookupOptions, "rdapLinkRels">,
|
||||
): string[] {
|
||||
const rels = (
|
||||
opts?.rdapLinkRels?.length
|
||||
? opts.rdapLinkRels
|
||||
: ["related", "entity", "registrar", "alternate"]
|
||||
opts?.rdapLinkRels?.length ? opts.rdapLinkRels : ["related", "entity", "registrar", "alternate"]
|
||||
).map((r) => r.toLowerCase());
|
||||
const d = (doc ?? {}) as Record<string, unknown> & { links?: RdapLink[] };
|
||||
const arr = Array.isArray(d?.links) ? (d.links as RdapLink[]) : [];
|
||||
|
||||
+15
-14
@@ -6,38 +6,40 @@ import { extractRdapRelatedLinks } from "./links";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
/** Coerce a loosely-typed JSON field to a string ("" for null/objects/etc.). */
|
||||
function str(val: unknown): string {
|
||||
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
|
||||
? String(val)
|
||||
: "";
|
||||
}
|
||||
|
||||
/** Merge RDAP documents with a conservative, additive strategy. */
|
||||
export function mergeRdapDocs(baseDoc: unknown, others: unknown[]): unknown {
|
||||
const merged: Json = { ...(baseDoc as Json) };
|
||||
for (const doc of others) {
|
||||
const cur = (doc ?? {}) as Json;
|
||||
// status: array of strings
|
||||
merged.status = uniqStrings([
|
||||
...toStringArray(merged.status),
|
||||
...toStringArray(cur.status),
|
||||
]);
|
||||
merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
|
||||
// events: array of objects; dedupe by eventAction + eventDate
|
||||
merged.events = uniqBy(
|
||||
[...toArray<Json>(merged.events), ...toArray<Json>(cur.events)],
|
||||
(e) =>
|
||||
`${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`,
|
||||
(e) => `${str(e?.eventAction).toLowerCase()}|${str(e?.eventDate)}`,
|
||||
);
|
||||
// nameservers: array of objects; dedupe by ldhName/unicodeName
|
||||
merged.nameservers = uniqBy(
|
||||
[...toArray<Json>(merged.nameservers), ...toArray<Json>(cur.nameservers)],
|
||||
(n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`,
|
||||
(n) => `${str(n?.ldhName ?? n?.unicodeName).toLowerCase()}`,
|
||||
);
|
||||
// entities: array; dedupe by handle if present, else by roles+vcard hash
|
||||
merged.entities = uniqBy(
|
||||
[...toArray<Json>(merged.entities), ...toArray<Json>(cur.entities)],
|
||||
(e) =>
|
||||
`${String(e?.handle ?? "").toLowerCase()}|${String(
|
||||
`${str(e?.handle).toLowerCase()}|${String(
|
||||
JSON.stringify(e?.roles || []),
|
||||
).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`,
|
||||
);
|
||||
// secureDNS: prefer existing; fill if missing
|
||||
if (merged.secureDNS == null && cur.secureDNS != null)
|
||||
merged.secureDNS = cur.secureDNS;
|
||||
if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
|
||||
// port43 (authoritative WHOIS): prefer existing; fill if missing
|
||||
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
|
||||
// remarks: concat simple strings if present
|
||||
@@ -59,8 +61,7 @@ export async function fetchAndMergeRdapRelated(
|
||||
opts?: LookupOptions,
|
||||
): Promise<{ merged: unknown; serversTried: string[] }> {
|
||||
const tried: string[] = [];
|
||||
if (opts?.rdapFollowLinks === false)
|
||||
return { merged: baseDoc, serversTried: tried };
|
||||
if (opts?.rdapFollowLinks === false) return { merged: baseDoc, serversTried: tried };
|
||||
const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
|
||||
if (maxHops === 0) return { merged: baseDoc, serversTried: tried };
|
||||
|
||||
@@ -83,8 +84,8 @@ export async function fetchAndMergeRdapRelated(
|
||||
tried.push(url);
|
||||
// only accept docs that appear related to the same domain when possible
|
||||
// if ldhName/unicodeName present, they should match the queried domain (case-insensitive)
|
||||
const ldh = String((json as Json)?.ldhName ?? "").toLowerCase();
|
||||
const uni = String((json as Json)?.unicodeName ?? "").toLowerCase();
|
||||
const ldh = str((json as Json)?.ldhName).toLowerCase();
|
||||
const uni = str((json as Json)?.unicodeName).toLowerCase();
|
||||
if (ldh && !sameDomain(ldh, domain)) continue;
|
||||
if (uni && !sameDomain(uni, domain)) continue;
|
||||
fetchedDocs.push(json);
|
||||
|
||||
@@ -47,9 +47,7 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
|
||||
],
|
||||
secureDNS: {
|
||||
delegationSigned: true,
|
||||
dsData: [
|
||||
{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" },
|
||||
],
|
||||
dsData: [{ keyTag: 12345, algorithm: 13, digestType: 2, digest: "ABCDEF" }],
|
||||
},
|
||||
events: [
|
||||
{ eventAction: "registration", eventDate: "2020-01-02T03:04:05Z" },
|
||||
@@ -59,9 +57,7 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
|
||||
status: ["clientTransferProhibited"],
|
||||
port43: "whois.example-registrar.test",
|
||||
};
|
||||
const rec = normalizeRdap("example.com", "com", rdap, [
|
||||
"https://rdap.example/",
|
||||
]);
|
||||
const rec = normalizeRdap("example.com", "com", rdap, ["https://rdap.example/"]);
|
||||
expect(rec.domain).toBe("example.com");
|
||||
expect(rec.tld).toBe("com");
|
||||
expect(rec.registrar?.name).toBe("Registrar LLC");
|
||||
@@ -95,9 +91,7 @@ test("normalizeRdap derives privacyEnabled from registrant keywords", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const rec = normalizeRdap("example.com", "com", rdap, [
|
||||
"https://rdap.example/",
|
||||
]);
|
||||
const rec = normalizeRdap("example.com", "com", rdap, ["https://rdap.example/"]);
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+15
-48
@@ -1,12 +1,7 @@
|
||||
import { toISO } from "../lib/dates";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
|
||||
import type {
|
||||
Contact,
|
||||
DomainRecord,
|
||||
Nameserver,
|
||||
RegistrarInfo,
|
||||
} from "../types";
|
||||
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
|
||||
|
||||
type RdapDoc = Record<string, unknown>;
|
||||
|
||||
@@ -24,24 +19,17 @@ export function normalizeRdap(
|
||||
const doc = (rdap ?? {}) as RdapDoc;
|
||||
|
||||
// Prefer ldhName (punycode) and unicodeName if provided
|
||||
const ldhName: string | undefined =
|
||||
asString(doc.ldhName) || asString(doc.handle);
|
||||
const ldhName: string | undefined = asString(doc.ldhName) || asString(doc.handle);
|
||||
const unicodeName: string | undefined = asString(doc.unicodeName);
|
||||
|
||||
// Registrar entity can be provided with role "registrar"
|
||||
const registrar: RegistrarInfo | undefined = extractRegistrar(
|
||||
doc.entities as unknown,
|
||||
);
|
||||
const registrar: RegistrarInfo | undefined = extractRegistrar(doc.entities as unknown);
|
||||
|
||||
// Nameservers: normalize host + IPs
|
||||
const nameservers: Nameserver[] | undefined = Array.isArray(doc.nameservers)
|
||||
? (doc.nameservers as RdapDoc[])
|
||||
.map((ns) => {
|
||||
const host = (
|
||||
asString(ns.ldhName) ??
|
||||
asString(ns.unicodeName) ??
|
||||
""
|
||||
).toLowerCase();
|
||||
const host = (asString(ns.ldhName) ?? asString(ns.unicodeName) ?? "").toLowerCase();
|
||||
const ip = ns.ipAddresses as RdapDoc | undefined;
|
||||
const ipv4 = asStringArray(ip?.v4);
|
||||
const ipv6 = asStringArray(ip?.v6);
|
||||
@@ -54,17 +42,13 @@ export function normalizeRdap(
|
||||
: undefined;
|
||||
|
||||
// Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse
|
||||
const contacts: Contact[] | undefined = extractContacts(
|
||||
doc.entities as unknown,
|
||||
);
|
||||
const contacts: Contact[] | undefined = extractContacts(doc.entities as unknown);
|
||||
|
||||
// Derive privacy flag from registrant name/org keywords
|
||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||
const privacyEnabled = !!(
|
||||
registrant &&
|
||||
(
|
||||
[registrant.name, registrant.organization].filter(Boolean) as string[]
|
||||
).some(isPrivacyName)
|
||||
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
|
||||
);
|
||||
|
||||
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
|
||||
@@ -99,21 +83,16 @@ export function normalizeRdap(
|
||||
: [];
|
||||
const byAction = (action: string) =>
|
||||
events.find(
|
||||
(e) =>
|
||||
typeof e?.eventAction === "string" &&
|
||||
e.eventAction.toLowerCase().includes(action),
|
||||
(e) => typeof e?.eventAction === "string" && e.eventAction.toLowerCase().includes(action),
|
||||
);
|
||||
const creationDate = toISO(
|
||||
asDateLike(byAction("registration")?.eventDate) ??
|
||||
asDateLike(doc.registrationDate),
|
||||
asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate),
|
||||
);
|
||||
const updatedDate = toISO(
|
||||
asDateLike(byAction("last changed")?.eventDate) ??
|
||||
asDateLike(doc.lastChangedDate),
|
||||
asDateLike(byAction("last changed")?.eventDate) ?? asDateLike(doc.lastChangedDate),
|
||||
);
|
||||
const expirationDate = toISO(
|
||||
asDateLike(byAction("expiration")?.eventDate) ??
|
||||
asDateLike(doc.expirationDate),
|
||||
asDateLike(byAction("expiration")?.eventDate) ?? asDateLike(doc.expirationDate),
|
||||
);
|
||||
const deletionDate = toISO(
|
||||
asDateLike(byAction("deletion")?.eventDate) ?? asDateLike(doc.deletionDate),
|
||||
@@ -164,9 +143,7 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
|
||||
if (!Array.isArray(entities)) return undefined;
|
||||
for (const ent of entities) {
|
||||
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
|
||||
? ((ent as RdapDoc).roles as unknown[]).filter(
|
||||
(r): r is string => typeof r === "string",
|
||||
)
|
||||
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
|
||||
: [];
|
||||
if (!roles.some((r) => /registrar/i.test(r))) continue;
|
||||
const v = parseVcard((ent as RdapDoc)?.vcardArray);
|
||||
@@ -191,9 +168,7 @@ function extractContacts(entities: unknown): Contact[] | undefined {
|
||||
const out: Contact[] = [];
|
||||
for (const ent of entities) {
|
||||
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
|
||||
? ((ent as RdapDoc).roles as unknown[]).filter(
|
||||
(r): r is string => typeof r === "string",
|
||||
)
|
||||
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
|
||||
: [];
|
||||
const v = parseVcard((ent as RdapDoc)?.vcardArray);
|
||||
const type = roles.find((r) =>
|
||||
@@ -245,15 +220,9 @@ interface ParsedVCard {
|
||||
// Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields
|
||||
function parseVcard(vcardArray: unknown): ParsedVCard {
|
||||
// vcardArray is typically ["vcard", [["version",{} ,"text","4.0"], ["fn",{} ,"text","Example"], ...]]
|
||||
if (
|
||||
!Array.isArray(vcardArray) ||
|
||||
vcardArray[0] !== "vcard" ||
|
||||
!Array.isArray(vcardArray[1])
|
||||
)
|
||||
if (!Array.isArray(vcardArray) || vcardArray[0] !== "vcard" || !Array.isArray(vcardArray[1]))
|
||||
return {};
|
||||
const entries = vcardArray[1] as Array<
|
||||
[string, Record<string, unknown>, string, unknown]
|
||||
>;
|
||||
const entries = vcardArray[1] as Array<[string, Record<string, unknown>, string, unknown]>;
|
||||
const out: ParsedVCard = {};
|
||||
for (const e of entries) {
|
||||
const key = e?.[0];
|
||||
@@ -264,9 +233,7 @@ function parseVcard(vcardArray: unknown): ParsedVCard {
|
||||
out.fn = asString(value);
|
||||
break;
|
||||
case "org":
|
||||
out.org = Array.isArray(value)
|
||||
? value.map((x) => String(x)).join(" ")
|
||||
: asString(value);
|
||||
out.org = Array.isArray(value) ? value.map((x) => String(x)).join(" ") : asString(value);
|
||||
break;
|
||||
case "email":
|
||||
out.email = asString(value);
|
||||
|
||||
+1
-4
@@ -372,7 +372,4 @@ export interface LookupResult {
|
||||
* Used internally for dependency injection and testing. Matches the signature
|
||||
* of the global `fetch` function available in Node.js 18+ and browsers.
|
||||
*/
|
||||
export type FetchLike = (
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
@@ -31,13 +31,7 @@ describe("WHOIS coalescing", () => {
|
||||
|
||||
const [first] = chain;
|
||||
if (!first) throw new Error("Expected first record");
|
||||
const base = normalizeWhois(
|
||||
"gitpod.io",
|
||||
"io",
|
||||
first.text,
|
||||
first.serverQueried,
|
||||
false,
|
||||
);
|
||||
const base = normalizeWhois("gitpod.io", "io", first.text, first.serverQueried, false);
|
||||
const merged = mergeWhoisRecords(base, []);
|
||||
expect(merged.isRegistered).toBe(true);
|
||||
expect(merged.creationDate).toBeDefined();
|
||||
|
||||
+4
-16
@@ -1,10 +1,7 @@
|
||||
import { uniq } from "../lib/text";
|
||||
import type { Contact, DomainRecord, Nameserver } from "../types";
|
||||
|
||||
function dedupeStatuses(
|
||||
a?: DomainRecord["statuses"],
|
||||
b?: DomainRecord["statuses"],
|
||||
) {
|
||||
function dedupeStatuses(a?: DomainRecord["statuses"], b?: DomainRecord["statuses"]) {
|
||||
const list = [...(a || []), ...(b || [])];
|
||||
const seen = new Set<string>();
|
||||
const out: NonNullable<DomainRecord["statuses"]> = [];
|
||||
@@ -48,10 +45,7 @@ function dedupeContacts(a?: Contact[], b?: Contact[]) {
|
||||
}
|
||||
|
||||
/** Conservative merge: start with base; fill missing scalars; union arrays; prefer more informative dates. */
|
||||
export function mergeWhoisRecords(
|
||||
base: DomainRecord,
|
||||
others: DomainRecord[],
|
||||
): DomainRecord {
|
||||
export function mergeWhoisRecords(base: DomainRecord, others: DomainRecord[]): DomainRecord {
|
||||
const merged: DomainRecord = { ...base };
|
||||
for (const cur of others) {
|
||||
merged.isRegistered = merged.isRegistered || cur.isRegistered;
|
||||
@@ -60,15 +54,9 @@ export function mergeWhoisRecords(
|
||||
merged.reseller = merged.reseller ?? cur.reseller;
|
||||
merged.statuses = dedupeStatuses(merged.statuses, cur.statuses);
|
||||
// Dates: prefer earliest creation, latest updated/expiration when available
|
||||
merged.creationDate = preferEarliestIso(
|
||||
merged.creationDate,
|
||||
cur.creationDate,
|
||||
);
|
||||
merged.creationDate = preferEarliestIso(merged.creationDate, cur.creationDate);
|
||||
merged.updatedDate = preferLatestIso(merged.updatedDate, cur.updatedDate);
|
||||
merged.expirationDate = preferLatestIso(
|
||||
merged.expirationDate,
|
||||
cur.expirationDate,
|
||||
);
|
||||
merged.expirationDate = preferLatestIso(merged.expirationDate, cur.expirationDate);
|
||||
merged.deletionDate = merged.deletionDate ?? cur.deletionDate;
|
||||
merged.transferLock = Boolean(merged.transferLock || cur.transferLock);
|
||||
merged.dnssec = merged.dnssec ?? cur.dnssec;
|
||||
|
||||
@@ -159,12 +159,7 @@ 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",
|
||||
);
|
||||
const rec = normalizeWhois("example.com", "com", text, "whois.verisign-grs.com");
|
||||
expect(Boolean(rec.creationDate)).toBe(true);
|
||||
expect(Boolean(rec.expirationDate)).toBe(true);
|
||||
expect(rec.source).toBe("whois");
|
||||
@@ -178,12 +173,7 @@ Registrar URL: http://www.registrar.test
|
||||
Registrant Name: REDACTED FOR PRIVACY
|
||||
Registrant Organization: Example Org
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.com",
|
||||
"com",
|
||||
text,
|
||||
"whois.verisign-grs.com",
|
||||
);
|
||||
const rec = normalizeWhois("example.com", "com", text, "whois.verisign-grs.com");
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+15
-65
@@ -1,12 +1,7 @@
|
||||
import { toISO } from "../lib/dates";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { parseKeyValueLines, uniq } from "../lib/text";
|
||||
import type {
|
||||
Contact,
|
||||
DomainRecord,
|
||||
Nameserver,
|
||||
RegistrarInfo,
|
||||
} from "../types";
|
||||
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
|
||||
|
||||
// Common WHOIS availability phrases seen across registries/registrars
|
||||
const WHOIS_AVAILABLE_PATTERNS: RegExp[] = [
|
||||
@@ -123,11 +118,7 @@ export function normalizeWhois(
|
||||
"organisation",
|
||||
"record maintained by",
|
||||
]);
|
||||
const ianaId = anyValue(map, [
|
||||
"registrar iana id",
|
||||
"sponsoring registrar iana id",
|
||||
"iana id",
|
||||
]);
|
||||
const ianaId = anyValue(map, ["registrar iana id", "sponsoring registrar iana id", "iana id"]);
|
||||
const url = anyValue(map, [
|
||||
"registrar url",
|
||||
"registrar website",
|
||||
@@ -135,16 +126,9 @@ export function normalizeWhois(
|
||||
"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;
|
||||
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,
|
||||
@@ -222,15 +206,11 @@ export function normalizeWhois(
|
||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||
const privacyEnabled = !!(
|
||||
registrant &&
|
||||
(
|
||||
[registrant.name, registrant.organization].filter(Boolean) as string[]
|
||||
).some(isPrivacyName)
|
||||
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
|
||||
);
|
||||
|
||||
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
||||
const dnssec = dnssecRaw
|
||||
? { enabled: /signed|yes|true/.test(dnssecRaw) }
|
||||
: undefined;
|
||||
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;
|
||||
|
||||
// Simple lock derivation from statuses
|
||||
const transferLock = !!statuses?.some((s) =>
|
||||
@@ -268,10 +248,7 @@ export function normalizeWhois(
|
||||
return record;
|
||||
}
|
||||
|
||||
function anyValue(
|
||||
map: Record<string, string[]>,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
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];
|
||||
@@ -327,11 +304,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
nameKeys.push("owner name"); // .tm
|
||||
}
|
||||
|
||||
orgKeys.push(
|
||||
`${prefix} organization`,
|
||||
`${prefix} organisation`,
|
||||
`${prefix} org`,
|
||||
);
|
||||
orgKeys.push(`${prefix} organization`, `${prefix} organisation`, `${prefix} org`);
|
||||
if (prefix === "registrant") {
|
||||
orgKeys.push("trading as"); // .uk, .co.uk
|
||||
orgKeys.push("org"); // .ru
|
||||
@@ -340,42 +313,22 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
orgKeys.push("owner orgname"); // .tm
|
||||
}
|
||||
|
||||
emailKeys.push(
|
||||
`${prefix} email`,
|
||||
`${prefix} contact email`,
|
||||
`${prefix} e-mail`,
|
||||
);
|
||||
emailKeys.push(`${prefix} email`, `${prefix} contact email`, `${prefix} e-mail`);
|
||||
|
||||
phoneKeys.push(
|
||||
`${prefix} phone`,
|
||||
`${prefix} contact phone`,
|
||||
`${prefix} telephone`,
|
||||
);
|
||||
phoneKeys.push(`${prefix} phone`, `${prefix} contact phone`, `${prefix} telephone`);
|
||||
|
||||
faxKeys.push(`${prefix} fax`, `${prefix} facsimile`);
|
||||
|
||||
streetKeys.push(
|
||||
`${prefix} street`,
|
||||
`${prefix} address`,
|
||||
`${prefix}'s address`,
|
||||
);
|
||||
streetKeys.push(`${prefix} street`, `${prefix} address`, `${prefix}'s address`);
|
||||
if (prefix === "owner") {
|
||||
streetKeys.push("owner addr"); // .tm
|
||||
}
|
||||
|
||||
cityKeys.push(`${prefix} city`);
|
||||
|
||||
stateKeys.push(
|
||||
`${prefix} state`,
|
||||
`${prefix} province`,
|
||||
`${prefix} state/province`,
|
||||
);
|
||||
stateKeys.push(`${prefix} state`, `${prefix} province`, `${prefix} state/province`);
|
||||
|
||||
postalCodeKeys.push(
|
||||
`${prefix} postal code`,
|
||||
`${prefix} postcode`,
|
||||
`${prefix} zip`,
|
||||
);
|
||||
postalCodeKeys.push(`${prefix} postal code`, `${prefix} postcode`, `${prefix} zip`);
|
||||
|
||||
countryKeys.push(`${prefix} country`);
|
||||
}
|
||||
@@ -410,10 +363,7 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
|
||||
return contacts.length ? contacts : undefined;
|
||||
}
|
||||
|
||||
function multi(
|
||||
map: Record<string, string[]>,
|
||||
keys: string[],
|
||||
): string[] | 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;
|
||||
|
||||
@@ -32,11 +32,10 @@ describe("WHOIS referral contradiction handling", () => {
|
||||
});
|
||||
|
||||
it("collects chain and does not append contradictory registrar", async () => {
|
||||
const chain = await collectWhoisReferralChain(
|
||||
"whois.nic.io",
|
||||
"raindrop.io",
|
||||
{ followWhoisReferral: true, maxWhoisReferralHops: 2 },
|
||||
);
|
||||
const chain = await collectWhoisReferralChain("whois.nic.io", "raindrop.io", {
|
||||
followWhoisReferral: true,
|
||||
maxWhoisReferralHops: 2,
|
||||
});
|
||||
expect(Array.isArray(chain)).toBe(true);
|
||||
// Mocked registrar is contradictory, so chain should contain only the TLD response
|
||||
expect(chain.length).toBe(1);
|
||||
|
||||
Reference in New Issue
Block a user