diff --git a/README.md b/README.md index 779acc2..1919a29 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,12 @@ interface DomainRecord { url?: string; email?: string; phone?: string; + street?: string[]; // address (RDAP only) + city?: string; + state?: string; + postalCode?: string; + country?: string; + countryCode?: string; // ISO 3166-1 alpha-2, from the vCard "cc" parameter }; reseller?: string; statuses?: Array<{ @@ -549,7 +555,15 @@ interface DomainRecord { country?: string; countryCode?: string; }>; - privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics + privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics or RFC 9537 redactions + redactions?: Array<{ + name: string; // e.g. "Registrant Email" + prePath?: string; + postPath?: string; + replacementPath?: string; + method?: string; // e.g. "emptyValue", "partialValue" + reason?: string; + }>; // RFC 9537 redaction metadata (RDAP only) whoisServer?: string; // authoritative WHOIS queried (if any) rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links) rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw) diff --git a/src/rdap/merge.ts b/src/rdap/merge.ts index 5942bda..25ca034 100644 --- a/src/rdap/merge.ts +++ b/src/rdap/merge.ts @@ -43,6 +43,13 @@ export function mergeRdapDocs(baseDoc: unknown, others: unknown[]): unknown { 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; + // redacted (RFC 9537): concat, dedupe by JSON + if (merged.redacted != null || cur.redacted != null) { + merged.redacted = uniqBy( + [...toArray(merged.redacted), ...toArray(cur.redacted)], + (r) => JSON.stringify(r), + ); + } // remarks: concat simple strings if present const mergedRemarks = (merged as { remarks?: Json[] }).remarks; const curRemarks = (cur as { remarks?: Json[] }).remarks; diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index bd0cfa4..4efeb00 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -135,3 +135,135 @@ test("normalizeRdap treats release-pending statuses as not registered", () => { ); expect(active.isRegistered).toBe(true); }); + +test("normalizeRdap reads vCard adr cc parameter into countryCode", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + entities: [ + { + roles: ["registrant"], + vcardArray: [ + "vcard", + [["adr", { cc: "us" }, "text", ["", "", "1 Main St", "Town", "CA", "90000", "USA"]]], + ], + }, + ], + }, + [], + ); + expect(rec.contacts?.[0]?.country).toBe("USA"); + expect(rec.contacts?.[0]?.countryCode).toBe("US"); +}); + +test("normalizeRdap parses RFC 9537 redacted array and flags privacy", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + redacted: [ + { + name: { description: "Registrant Email" }, + prePath: "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='email')]", + method: "emptyValue", + reason: { description: "Server policy" }, + }, + ], + }, + [], + ); + expect(rec.redactions).toEqual([ + expect.objectContaining({ + name: "Registrant Email", + method: "emptyValue", + reason: "Server policy", + }), + ]); + expect(rec.privacyEnabled).toBe(true); +}); + +test("normalizeRdap separates fax from tel and keeps multiple emails", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + entities: [ + { + roles: ["registrant"], + vcardArray: [ + "vcard", + [ + ["tel", { type: "voice" }, "text", "+1.111"], + ["tel", { type: ["work", "fax"] }, "text", "+1.222"], + ["email", {}, "text", "a@example.com"], + ["email", {}, "text", "b@example.com"], + ["adr", {}, "text", ["", "", "Suite 5, 1 Main St", "Town", "", "", ""]], + ], + ], + }, + ], + }, + [], + ); + const c = rec.contacts?.[0]; + expect(c?.phone).toBe("+1.111"); + expect(c?.fax).toBe("+1.222"); + expect(c?.email).toEqual(["a@example.com", "b@example.com"]); + expect(c?.street).toEqual(["Suite 5, 1 Main St"]); +}); + +test("normalizeRdap prefers registration event over reregistration", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + events: [ + { eventAction: "reregistration", eventDate: "2024-01-01T00:00:00Z" }, + { eventAction: "registration", eventDate: "2020-01-01T00:00:00Z" }, + ], + }, + [], + ); + expect(rec.creationDate).toBe("2020-01-01T00:00:00Z"); +}); + +test("normalizeRdap maps registrar adr and cc", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + entities: [ + { + roles: ["registrar"], + vcardArray: [ + "vcard", + [ + ["fn", {}, "text", "Registrar LLC"], + [ + "adr", + { cc: "CA" }, + "text", + ["", "", "5 King St", "Toronto", "ON", "M5H", "Canada"], + ], + ], + ], + }, + ], + }, + [], + ); + expect(rec.registrar).toMatchObject({ + street: ["5 King St"], + city: "Toronto", + state: "ON", + postalCode: "M5H", + country: "Canada", + countryCode: "CA", + }); +}); diff --git a/src/rdap/normalize.ts b/src/rdap/normalize.ts index 93f96ff..3577597 100644 --- a/src/rdap/normalize.ts +++ b/src/rdap/normalize.ts @@ -1,7 +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, Redaction, RegistrarInfo } from "../types"; type RdapDoc = Record; @@ -44,12 +44,16 @@ export function normalizeRdap( // Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse const contacts: Contact[] | undefined = extractContacts(doc.entities as unknown); - // Derive privacy flag from registrant name/org keywords + // RFC 9537 redaction metadata + const redactions = extractRedactions(doc.redacted); + + // Derive privacy flag from registrant name/org keywords or RFC 9537 registrant redactions const registrant = contacts?.find((c) => c.type === "registrant"); - const privacyEnabled = !!( - registrant && - ([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) - ); + const privacyEnabled = + !!( + registrant && + ([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) + ) || !!redactions?.some((r) => /registrant/i.test(`${r.prePath ?? ""} ${r.name}`)); // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present. const statuses = Array.isArray(doc.status) @@ -81,10 +85,14 @@ export function normalizeRdap( const events: RdapEvent[] = Array.isArray(doc.events) ? (doc.events as unknown[] as RdapEvent[]) : []; + const actionOf = (e: RdapEvent) => (typeof e?.eventAction === "string" ? e.eventAction : ""); + // Prefer an exact match, then a substring match that ignores "reregistration" (RFC 9083) const byAction = (action: string) => - events.find( - (e) => typeof e?.eventAction === "string" && e.eventAction.toLowerCase().includes(action), - ); + events.find((e) => actionOf(e).toLowerCase() === action) ?? + events.find((e) => { + const a = actionOf(e).toLowerCase(); + return a.includes(action) && a !== "reregistration"; + }); const creationDate = toISO( asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate), ); @@ -135,6 +143,7 @@ export function normalizeRdap( : undefined, contacts, privacyEnabled: privacyEnabled ? true : undefined, + redactions, whoisServer, rdapServers: rdapServersTried, rawRdap: includeRaw ? rdap : undefined, @@ -146,6 +155,29 @@ export function normalizeRdap( return record; } +/** Parse the RFC 9537 top-level "redacted" array. */ +function extractRedactions(redacted: unknown): Redaction[] | undefined { + if (!Array.isArray(redacted)) return undefined; + const out: Redaction[] = []; + for (const item of redacted) { + if (!item || typeof item !== "object") continue; + const r = item as RdapDoc; + const nameObj = (r.name ?? {}) as RdapDoc; + const name = asString(nameObj.description) || asString(nameObj.type); + if (!name) continue; + const reason = (r.reason ?? {}) as RdapDoc; + out.push({ + name, + prePath: asString(r.prePath) || undefined, + postPath: asString(r.postPath) || undefined, + replacementPath: asString(r.replacementPath) || undefined, + method: asString(r.method) || undefined, + reason: asString(reason.description) || asString(reason.type) || undefined, + }); + } + return out.length ? out : undefined; +} + function extractRegistrar(entities: unknown): RegistrarInfo | undefined { if (!Array.isArray(entities)) return undefined; for (const ent of entities) { @@ -163,8 +195,14 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined { name: v.fn || v.org || asString((ent as RdapDoc)?.handle) || undefined, ianaId: asString(ianaId), url: v.url ?? undefined, - email: v.email ?? undefined, - phone: v.tel ?? undefined, + email: v.email?.[0], + phone: v.tel?.[0], + street: v.street, + city: v.locality, + state: v.region, + postalCode: v.postcode, + country: v.country, + countryCode: v.countryCode, }; } return undefined; @@ -195,9 +233,9 @@ function extractContacts(entities: unknown): Contact[] | undefined { type: roleKey, name: v.fn, organization: v.org, - email: v.email, - phone: v.tel, - fax: v.fax, + email: single(v.email), + phone: single(v.tel), + fax: single(v.fax), street: v.street, city: v.locality, state: v.region, @@ -209,12 +247,18 @@ function extractContacts(entities: unknown): Contact[] | undefined { return out.length ? out : undefined; } +/** Collapse a list to undefined, a single string, or the array when there are several. */ +function single(list: string[] | undefined): string | string[] | undefined { + if (!list?.length) return undefined; + return list.length === 1 ? list[0] : list; +} + interface ParsedVCard { fn?: string; org?: string; - email?: string; - tel?: string; - fax?: string; + email?: string[]; + tel?: string[]; + fax?: string[]; url?: string; street?: string[]; locality?: string; @@ -242,28 +286,38 @@ function parseVcard(vcardArray: unknown): ParsedVCard { case "org": out.org = Array.isArray(value) ? value.map((x) => String(x)).join(" ") : asString(value); break; - case "email": - out.email = asString(value); + case "email": { + const v = asString(value); + if (v) (out.email ??= []).push(v); break; - case "tel": - out.tel = asString(value); + } + case "tel": { + const v = asString(value); + if (!v) break; + // RFC 6350 TYPE parameter may be a string or array (e.g. "fax", ["work", "fax"]) + const type = e?.[1]?.type; + const types = (Array.isArray(type) ? type : [type]).map((t) => String(t).toLowerCase()); + (types.includes("fax") ? (out.fax ??= []) : (out.tel ??= [])).push(v); break; + } case "url": out.url = asString(value); break; case "adr": { // adr value is [postOfficeBox, extendedAddress, street, locality, region, postalCode, country] if (Array.isArray(value)) { - out.street = value[2] ? String(value[2]).split(/\n|,\s*/) : undefined; + out.street = value[2] ? String(value[2]).split(/\r?\n/).filter(Boolean) : undefined; out.locality = asString(value[3]); out.region = asString(value[4]); out.postcode = asString(value[5]); out.country = asString(value[6]); + // RFC 8605: ISO 3166-1 alpha-2 code lives in the "cc" parameter + const cc = asString(e?.[1]?.cc); + if (cc) out.countryCode = cc.toUpperCase(); } break; } } } - // Best effort country code from country name (often omitted). Leaving undefined unless explicitly provided. return out; } diff --git a/src/types.ts b/src/types.ts index 88495f2..3fdcbf8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,14 @@ export interface RegistrarInfo { email?: string; /** Registrar contact phone number */ phone?: string; + /** Registrar street address lines */ + street?: string[]; + city?: string; + state?: string; + postalCode?: string; + country?: string; + /** ISO 3166-1 alpha-2 country code, when provided */ + countryCode?: string; } /** @@ -55,6 +63,24 @@ export interface Contact { countryCode?: string; } +/** + * An RFC 9537 redaction entry describing a field the registry withheld. + */ +export interface Redaction { + /** Human-readable name/type of the redacted field (e.g., "Registrant Email") */ + name: string; + /** JSONPath to the redacted field's parent/location */ + prePath?: string; + /** JSONPath to the redacted field itself, if present */ + postPath?: string; + /** JSONPath to a replacement value, if any */ + replacementPath?: string; + /** Redaction method (e.g., "emptyValue", "partialValue", "replacementValue", "removal") */ + method?: string; + /** Reason for redaction (description or type) */ + reason?: string; +} + /** * DNS nameserver information. * @@ -162,6 +188,8 @@ export interface DomainRecord { contacts?: Contact[]; /** Best guess as to whether registrant is redacted based on keywords */ privacyEnabled?: boolean; + /** RFC 9537 redaction metadata reported by RDAP, if any */ + redactions?: Redaction[]; /** Authoritative WHOIS queried (if any) */ whoisServer?: string; /** RDAP base URLs tried */ diff --git a/src/whois/normalize.ts b/src/whois/normalize.ts index 6f07a0a..253edb0 100644 --- a/src/whois/normalize.ts +++ b/src/whois/normalize.ts @@ -370,6 +370,8 @@ function collectContacts(map: Record): Contact[] | undefined { state: state || undefined, postalCode: postalCode || undefined, country: country || undefined, + // Many registries print the ISO 3166-1 alpha-2 code directly + countryCode: country && /^[A-Za-z]{2}$/.test(country) ? country.toUpperCase() : undefined, }); } }