Refactor lookup and normalization functions to improve type safety and error handling. Updated domain lookup to provide a stable ISO string fallback and enhanced error messaging. Modified RDAP normalization to use stricter types and added utility functions for type assertions.

This commit is contained in:
2025-09-24 14:56:56 -04:00
parent 38cf9c5eaa
commit a788b0147a
6 changed files with 154 additions and 74 deletions
+6 -3
View File
@@ -22,7 +22,9 @@ export async function lookupDomain(
return { ok: false, error: "Input does not look like a domain" }; return { ok: false, error: "Input does not look like a domain" };
} }
const tld = extractTld(domain); const tld = extractTld(domain);
const now = toISO(new Date())!; // Avoid non-null assertion: fallback to a stable ISO string if parsing ever fails
const now =
toISO(new Date()) ?? new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
// If WHOIS-only, skip RDAP path // If WHOIS-only, skip RDAP path
if (!opts?.whoisOnly) { if (!opts?.whoisOnly) {
@@ -77,7 +79,8 @@ export async function lookupDomain(
now, now,
); );
return { ok: true, record }; return { ok: true, record };
} catch (err: any) { } catch (err: unknown) {
return { ok: false, error: String(err?.message || err) }; const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: message };
} }
} }
+115 -65
View File
@@ -4,7 +4,9 @@ import type {
Nameserver, Nameserver,
RegistrarInfo, RegistrarInfo,
} from "./types.js"; } from "./types.js";
import { toISO, uniq } from "./utils.js"; import { asDateLike, asString, asStringArray, toISO, uniq } from "./utils.js";
type RdapDoc = Record<string, unknown>;
/** /**
* Convert RDAP JSON into our normalized DomainRecord. * Convert RDAP JSON into our normalized DomainRecord.
@@ -13,59 +15,83 @@ import { toISO, uniq } from "./utils.js";
export function normalizeRdap( export function normalizeRdap(
inputDomain: string, inputDomain: string,
tld: string, tld: string,
rdap: any, rdap: unknown,
rdapServersTried: string[], rdapServersTried: string[],
fetchedAtISO: string, fetchedAtISO: string,
): DomainRecord { ): DomainRecord {
const doc = (rdap ?? {}) as RdapDoc;
// Safe helpers for optional fields // Safe helpers for optional fields
const get = (obj: any, path: string[]): any => const _get = (obj: unknown, path: string[]): unknown =>
path.reduce((o, k) => (o && k in o ? o[k] : undefined), obj); path.reduce<unknown>(
(o, k) => ((o as RdapDoc)?.[k] as unknown) ?? undefined,
obj,
);
// Prefer ldhName (punycode) and unicodeName if provided // Prefer ldhName (punycode) and unicodeName if provided
const ldhName: string | undefined = const ldhName: string | undefined =
rdap?.ldhName || rdap?.handle || undefined; asString(doc.ldhName) || asString(doc.handle);
const unicodeName: string | undefined = rdap?.unicodeName || undefined; const unicodeName: string | undefined = asString(doc.unicodeName);
// Registrar entity can be provided with role "registrar" // Registrar entity can be provided with role "registrar"
const registrar: RegistrarInfo | undefined = extractRegistrar(rdap?.entities); const registrar: RegistrarInfo | undefined = extractRegistrar(
doc.entities as unknown,
);
// Nameservers: normalize host + IPs // Nameservers: normalize host + IPs
const nameservers: Nameserver[] | undefined = Array.isArray(rdap?.nameservers) const nameservers: Nameserver[] | undefined = Array.isArray(doc.nameservers)
? rdap.nameservers ? (doc.nameservers as RdapDoc[])
.map((ns: any) => ({ .map((ns) => {
host: ns?.ldhName || ns?.unicodeName || "", const host = (
ipv4: get(ns, ["ipAddresses", "v4"]) || undefined, asString(ns.ldhName) ??
ipv6: get(ns, ["ipAddresses", "v6"]) || undefined, asString(ns.unicodeName) ??
})) ""
.filter((n: Nameserver) => !!n.host) ).toLowerCase();
const ip = ns.ipAddresses as RdapDoc | undefined;
const ipv4 = asStringArray(ip?.v4);
const ipv6 = asStringArray(ip?.v6);
const n: Nameserver = { host };
if (ipv4?.length) n.ipv4 = ipv4;
if (ipv6?.length) n.ipv6 = ipv6;
return n;
})
.filter((n) => !!n.host)
: undefined; : undefined;
// Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse // Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse
const contacts: Contact[] | undefined = extractContacts(rdap?.entities); const contacts: Contact[] | undefined = extractContacts(
doc.entities as unknown,
);
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present. // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
const statuses = Array.isArray(rdap?.status) const statuses = Array.isArray(doc.status)
? rdap.status.map((s: string) => ({ status: s, raw: s })) ? (doc.status as unknown[])
.filter((s): s is string => typeof s === "string")
.map((s) => ({ status: s, raw: s }))
: undefined; : undefined;
// Secure DNS info // Secure DNS info
const secureDNS = rdap?.secureDNS; const secureDNS = doc.secureDNS as
| { delegationSigned?: unknown; dsData?: Array<Record<string, unknown>> }
| undefined;
const dnssec = secureDNS const dnssec = secureDNS
? { ? {
enabled: !!secureDNS.delegationSigned, enabled: !!secureDNS.delegationSigned,
dsRecords: Array.isArray(secureDNS.dsData) dsRecords: Array.isArray(secureDNS.dsData)
? secureDNS.dsData.map((d: any) => ({ ? (secureDNS.dsData as Array<Record<string, unknown>>).map((d) => ({
keyTag: d.keyTag, keyTag: d.keyTag as number | undefined,
algorithm: d.algorithm, algorithm: d.algorithm as number | undefined,
digestType: d.digestType, digestType: d.digestType as number | undefined,
digest: d.digest, digest: d.digest as string | undefined,
})) }))
: undefined, : undefined,
} }
: undefined; : undefined;
// RDAP "events" contain timestamps for registration, last changed, expiration, deletion, etc. // RDAP "events" contain timestamps for registration, last changed, expiration, deletion, etc.
const events: any[] = Array.isArray(rdap?.events) ? rdap.events : []; type RdapEvent = { eventAction?: string; eventDate?: string | number | Date };
const events: RdapEvent[] = Array.isArray(doc.events)
? (doc.events as unknown[] as RdapEvent[])
: [];
const byAction = (action: string) => const byAction = (action: string) =>
events.find( events.find(
(e) => (e) =>
@@ -73,16 +99,19 @@ export function normalizeRdap(
e.eventAction.toLowerCase().includes(action), e.eventAction.toLowerCase().includes(action),
); );
const creationDate = toISO( const creationDate = toISO(
byAction("registration")?.eventDate || rdap?.registrationDate, asDateLike(byAction("registration")?.eventDate) ??
asDateLike(doc.registrationDate),
); );
const updatedDate = toISO( const updatedDate = toISO(
byAction("last changed")?.eventDate || rdap?.lastChangedDate, asDateLike(byAction("last changed")?.eventDate) ??
asDateLike(doc.lastChangedDate),
); );
const expirationDate = toISO( const expirationDate = toISO(
byAction("expiration")?.eventDate || rdap?.expirationDate, asDateLike(byAction("expiration")?.eventDate) ??
asDateLike(doc.expirationDate),
); );
const deletionDate = toISO( const deletionDate = toISO(
byAction("deletion")?.eventDate || rdap?.deletionDate, asDateLike(byAction("deletion")?.eventDate) ?? asDateLike(doc.deletionDate),
); );
// Derive a simple transfer lock flag from statuses // Derive a simple transfer lock flag from statuses
@@ -91,8 +120,7 @@ export function normalizeRdap(
); );
// The RDAP document may include "port43" pointer to authoritative WHOIS // The RDAP document may include "port43" pointer to authoritative WHOIS
const whoisServer: string | undefined = const whoisServer: string | undefined = asString(doc.port43);
typeof rdap?.port43 === "string" ? rdap.port43 : undefined;
const record: DomainRecord = { const record: DomainRecord = {
domain: unicodeName || ldhName || inputDomain, domain: unicodeName || ldhName || inputDomain,
@@ -126,35 +154,42 @@ export function normalizeRdap(
return record; return record;
} }
function extractRegistrar( function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
entities: any[] | undefined,
): RegistrarInfo | undefined {
if (!Array.isArray(entities)) return undefined; if (!Array.isArray(entities)) return undefined;
for (const ent of entities) { for (const ent of entities) {
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : []; const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
? ((ent as RdapDoc).roles as unknown[]).filter(
(r): r is string => typeof r === "string",
)
: [];
if (!roles.some((r) => /registrar/i.test(r))) continue; if (!roles.some((r) => /registrar/i.test(r))) continue;
const v = parseVcard(ent?.vcardArray); const v = parseVcard((ent as RdapDoc)?.vcardArray);
const ianaId = Array.isArray(ent?.publicIds) const ianaId = Array.isArray((ent as RdapDoc)?.publicIds)
? ent.publicIds.find((id: any) => /iana\s*registrar\s*id/i.test(id?.type)) ? ((ent as RdapDoc).publicIds as Array<RdapDoc>).find((id) =>
?.identifier /iana\s*registrar\s*id/i.test(String(id?.type)),
)?.identifier
: undefined; : undefined;
return { return {
name: v.fn || v.org || ent?.handle || undefined, name: v.fn || v.org || asString((ent as RdapDoc)?.handle) || undefined,
ianaId: ianaId, ianaId: asString(ianaId),
url: v.url, url: v.url ?? undefined,
email: v.email, email: v.email ?? undefined,
phone: v.tel, phone: v.tel ?? undefined,
}; };
} }
return undefined; return undefined;
} }
function extractContacts(entities: any[] | undefined): Contact[] | undefined { function extractContacts(entities: unknown): Contact[] | undefined {
if (!Array.isArray(entities)) return undefined; if (!Array.isArray(entities)) return undefined;
const out: Contact[] = []; const out: Contact[] = [];
for (const ent of entities) { for (const ent of entities) {
const roles: string[] = Array.isArray(ent?.roles) ? ent.roles : []; const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
const v = parseVcard(ent?.vcardArray); ? ((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) => const type = roles.find((r) =>
/registrant|administrative|technical|billing|abuse|reseller/i.test(r), /registrant|administrative|technical|billing|abuse|reseller/i.test(r),
); );
@@ -167,9 +202,7 @@ function extractContacts(entities: any[] | undefined): Contact[] | undefined {
abuse: "abuse", abuse: "abuse",
reseller: "reseller", reseller: "reseller",
} as const; } as const;
const roleKey = ( const roleKey = (map[type.toLowerCase()] ?? "unknown") as Contact["type"];
type.toLowerCase() in map ? (map as any)[type.toLowerCase()] : "unknown"
) as Contact["type"];
out.push({ out.push({
type: roleKey, type: roleKey,
name: v.fn, name: v.fn,
@@ -188,8 +221,23 @@ function extractContacts(entities: any[] | undefined): Contact[] | undefined {
return out.length ? out : undefined; return out.length ? out : undefined;
} }
interface ParsedVCard {
fn?: string;
org?: string;
email?: string;
tel?: string;
fax?: string;
url?: string;
street?: string[];
locality?: string;
region?: string;
postcode?: string;
country?: string;
countryCode?: string;
}
// Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields // Parse a minimal subset of vCard 4.0 arrays as used in RDAP "vcardArray" fields
function parseVcard(vcardArray: any): Record<string, any> { function parseVcard(vcardArray: unknown): ParsedVCard {
// vcardArray is typically ["vcard", [["version",{} ,"text","4.0"], ["fn",{} ,"text","Example"], ...]] // vcardArray is typically ["vcard", [["version",{} ,"text","4.0"], ["fn",{} ,"text","Example"], ...]]
if ( if (
!Array.isArray(vcardArray) || !Array.isArray(vcardArray) ||
@@ -197,8 +245,10 @@ function parseVcard(vcardArray: any): Record<string, any> {
!Array.isArray(vcardArray[1]) !Array.isArray(vcardArray[1])
) )
return {}; return {};
const entries: any[] = vcardArray[1]; const entries = vcardArray[1] as Array<
const out: Record<string, any> = {}; [string, Record<string, unknown>, string, unknown]
>;
const out: ParsedVCard = {};
for (const e of entries) { for (const e of entries) {
const key = e?.[0]; const key = e?.[0];
const _valueType = e?.[2]; const _valueType = e?.[2];
@@ -206,30 +256,30 @@ function parseVcard(vcardArray: any): Record<string, any> {
if (!key) continue; if (!key) continue;
switch (String(key).toLowerCase()) { switch (String(key).toLowerCase()) {
case "fn": case "fn":
out.fn = value; out.fn = asString(value);
break; break;
case "org": case "org":
out.org = Array.isArray(value) ? value.join(" ") : value; out.org = Array.isArray(value)
? value.map((x) => String(x)).join(" ")
: asString(value);
break; break;
case "email": case "email":
out.email = value; out.email = asString(value);
break; break;
case "tel": case "tel":
out.tel = value; out.tel = asString(value);
break; break;
case "url": case "url":
out.url = value; out.url = asString(value);
break; break;
case "adr": { case "adr": {
// adr value is [postOfficeBox, extendedAddress, street, locality, region, postalCode, country] // adr value is [postOfficeBox, extendedAddress, street, locality, region, postalCode, country]
if (Array.isArray(value)) { if (Array.isArray(value)) {
out.street = value[2] out.street = value[2] ? String(value[2]).split(/\n|,\s*/) : undefined;
? String(value[2]).split(/\\n|,\s*/) out.locality = asString(value[3]);
: undefined; out.region = asString(value[4]);
out.locality = value[3]; out.postcode = asString(value[5]);
out.region = value[4]; out.country = asString(value[6]);
out.postcode = value[5];
out.country = value[6];
} }
break; break;
} }
+1 -1
View File
@@ -57,7 +57,7 @@ export async function fetchRdapDomain(
domain: string, domain: string,
baseUrl: string, baseUrl: string,
options?: LookupOptions, options?: LookupOptions,
): Promise<{ url: string; json: any }> { ): Promise<{ url: string; json: unknown }> {
const url = new URL( const url = new URL(
`domain/${encodeURIComponent(domain)}`, `domain/${encodeURIComponent(domain)}`,
baseUrl, baseUrl,
+1 -1
View File
@@ -71,7 +71,7 @@ export interface DomainRecord {
contacts?: Contact[]; contacts?: Contact[];
whoisServer?: string; // authoritative WHOIS queried (if any) whoisServer?: string; // authoritative WHOIS queried (if any)
rdapServers?: string[]; // RDAP base URLs tried rdapServers?: string[]; // RDAP base URLs tried
rawRdap?: any; // raw RDAP JSON rawRdap?: unknown; // raw RDAP JSON
rawWhois?: string; // raw WHOIS text (last authoritative) rawWhois?: string; // raw WHOIS text (last authoritative)
source: LookupSource; // which source produced data source: LookupSource; // which source produced data
fetchedAt: string; // ISO 8601 fetchedAt: string; // ISO 8601
+2
View File
@@ -7,9 +7,11 @@ test("toISO parses ISO and common whois formats", () => {
assert.equal(iso, "2023-01-02T03:04:05Z"); assert.equal(iso, "2023-01-02T03:04:05Z");
const noZ = toISO("2023-01-02 03:04:05"); const noZ = toISO("2023-01-02 03:04:05");
// biome-ignore lint/style/noNonNullAssertion: this is fine
assert.match(noZ!, /^2023-01-02T03:04:05Z$/); assert.match(noZ!, /^2023-01-02T03:04:05Z$/);
const slash = toISO("2023/01/02 03:04:05"); const slash = toISO("2023/01/02 03:04:05");
// biome-ignore lint/style/noNonNullAssertion: this is fine
assert.match(slash!, /^2023-01-02T03:04:05Z$/); assert.match(slash!, /^2023-01-02T03:04:05Z$/);
const dmy = toISO("02-Jan-2023"); const dmy = toISO("02-Jan-2023");
+28 -3
View File
@@ -92,7 +92,7 @@ function parseWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const mon = monthMap[monStr.toLowerCase()]; const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd))); return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch { } catch {
return undefined; // fall through to undefined
} }
return undefined; return undefined;
} }
@@ -171,11 +171,16 @@ export function withTimeout<T>(
reason = "Timeout", reason = "Timeout",
): Promise<T> { ): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timer: any; let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => { const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(reason)), timeoutMs); timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
}); });
return Promise.race([promise.finally(() => clearTimeout(timer)), timeout]); return Promise.race([
promise.finally(() => {
if (timer !== undefined) clearTimeout(timer);
}),
timeout,
]);
} }
export function sleep(ms: number): Promise<void> { export function sleep(ms: number): Promise<void> {
@@ -191,3 +196,23 @@ export function extractTld(domain: string): string {
export function isLikelyDomain(input: string): boolean { export function isLikelyDomain(input: string): boolean {
return /^[a-z0-9.-]+$/i.test(input) && input.includes("."); return /^[a-z0-9.-]+$/i.test(input) && input.includes(".");
} }
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? (value.filter((x) => typeof x === "string") as string[])
: undefined;
}
export function asDateLike(value: unknown): string | number | Date | undefined {
if (
typeof value === "string" ||
typeof value === "number" ||
value instanceof Date
)
return value;
return undefined;
}