From 992c54ad281a64e45bc3d59ae05983709c342d9f Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 19 Sep 2026 23:20:15 -0400 Subject: [PATCH] refactor: extract vCard parser into its own module and expand parsed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move `parseVcard` out of `normalize.ts` into a new `src/rdap/vcard.ts` module; export `readJCard` (jCard → `VCardProp[]`) and `paramList` as reusable primitives - Parse `KIND` into a typed `VCardKind` union and split structured `ORG` values into a top-level `org` plus an `orgUnits` array for sub-levels - Parse `TITLE` and `ROLE` properties from the vCard - Extract the PO box (element 0) and extended address (element 1) from `ADR` into `poBox` and the beginning of `street` respectively, rather than discarding them - Strip `tel:` URI scheme from `TEL` entries whose `valueType` is `uri` (RFC 6350) - Add `kind`, `organizationUnits`, `title`, `role`, and `poBox` to the `Contact` type and wire them through `extractContacts` in `normalize.ts` - Document new `Contact` fields in README - Add `vcard.test.ts` covering malformed input tolerance, `paramList` normalisation, ORG level splitting, unknown KIND, PO box extraction, and `tel:` URI stripping --- README.md | 5 ++ src/rdap/normalize.test.ts | 18 +++++ src/rdap/normalize.ts | 79 +++----------------- src/rdap/vcard.test.ts | 62 ++++++++++++++++ src/rdap/vcard.ts | 144 +++++++++++++++++++++++++++++++++++++ src/types.ts | 88 ++++++++++++++++------- 6 files changed, 301 insertions(+), 95 deletions(-) create mode 100644 src/rdap/vcard.test.ts create mode 100644 src/rdap/vcard.ts diff --git a/README.md b/README.md index f19e85d..384f3b6 100644 --- a/README.md +++ b/README.md @@ -544,10 +544,15 @@ interface DomainRecord { type: "registrant" | "admin" | "tech" | "billing" | "abuse" | "registrar" | "reseller" | "unknown"; name?: string; + kind?: "individual" | "org" | "group" | "location"; // vCard KIND (RDAP only, when provided) organization?: string; + organizationUnits?: string[]; // vCard ORG levels below the organization (RDAP only) + title?: string; // RDAP only + role?: string; // RDAP only email?: string | string[]; phone?: string | string[]; fax?: string | string[]; + poBox?: string; // RDAP only street?: string[]; city?: string; state?: string; diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index 5731f2e..66d5139 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -306,3 +306,21 @@ test("normalizeRdap flags contacts with placeholder values or matching redaction expect(tech?.redacted).toBe(true); expect(admin?.redacted).toBeUndefined(); }); + +test("normalizeRdap resolves registrar countryCode from the country name", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + entities: [ + { + roles: ["registrar"], + vcardArray: ["vcard", [["adr", {}, "text", ["", "", "", "", "", "", "Canada"]]]], + }, + ], + }, + [], + ); + expect(rec.registrar).toMatchObject({ country: "Canada", countryCode: "CA" }); +}); diff --git a/src/rdap/normalize.ts b/src/rdap/normalize.ts index 6c489e4..5f2b523 100644 --- a/src/rdap/normalize.ts +++ b/src/rdap/normalize.ts @@ -1,7 +1,9 @@ +import { resolveCountry } from "../lib/countries"; import { finalizeContact } from "../lib/contacts"; import { toISO } from "../lib/dates"; import { isPrivacyName } from "../lib/privacy"; import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; +import { parseVcard } from "./vcard"; import type { Contact, DomainRecord, Nameserver, Redaction, RegistrarInfo } from "../types"; type RdapDoc = Record; @@ -204,8 +206,7 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined { city: v.locality, state: v.region, postalCode: v.postcode, - country: v.country, - countryCode: v.countryCode, + ...resolveCountry(v.country, v.countryCode), }; } return undefined; @@ -242,9 +243,14 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[] type: roleKey, name: v.fn, organization: v.org, + organizationUnits: v.orgUnits, + kind: v.kind, + title: v.title, + role: v.role, email: single(v.email), phone: single(v.tel), fax: single(v.fax), + poBox: v.poBox, street: v.street, city: v.locality, state: v.region, @@ -263,72 +269,3 @@ 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[]; - 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 -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])) - return {}; - const entries = vcardArray[1] as Array<[string, Record, string, unknown]>; - const out: ParsedVCard = {}; - for (const e of entries) { - const key = e?.[0]; - const value = e?.[3]; - if (!key) continue; - switch (String(key).toLowerCase()) { - case "fn": - out.fn = asString(value); - break; - case "org": - out.org = Array.isArray(value) ? value.map((x) => String(x)).join(" ") : asString(value); - break; - case "email": { - const v = asString(value); - if (v) (out.email ??= []).push(v); - break; - } - 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(/\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; - } - } - } - return out; -} diff --git a/src/rdap/vcard.test.ts b/src/rdap/vcard.test.ts new file mode 100644 index 0000000..50e0184 --- /dev/null +++ b/src/rdap/vcard.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; +import { paramList, parseVcard, readJCard } from "./vcard"; + +const jcard = (props: unknown[]) => ["vcard", [["version", {}, "text", "4.0"], ...props]]; + +test("readJCard tolerates malformed input", () => { + expect(readJCard(undefined)).toEqual([]); + expect(readJCard(["vcard", "nope"])).toEqual([]); + expect(readJCard(["vcard", [null, 5, ["fn"], ["FN", {}, "text", "A"]]])).toMatchObject([ + { name: "fn", valueType: "text", value: undefined }, + { name: "fn", valueType: "text", value: "A" }, + ]); +}); + +test("paramList normalizes string/array params", () => { + expect(paramList("FAX")).toEqual(["fax"]); + expect(paramList(["Work", "fax"])).toEqual(["work", "fax"]); + expect(paramList(undefined)).toEqual([]); +}); + +test("parseVcard splits org levels and reads kind/title/role", () => { + const v = parseVcard( + jcard([ + ["kind", {}, "text", "ORG"], + ["org", {}, "text", ["Acme Corp", "Sales", "EMEA"]], + ["title", {}, "text", "Head of Domains"], + ["role", {}, "text", "Admin"], + ]), + ); + expect(v).toMatchObject({ + kind: "org", + org: "Acme Corp", + orgUnits: ["Sales", "EMEA"], + title: "Head of Domains", + role: "Admin", + }); +}); + +test("parseVcard handles string org and ignores unknown kind", () => { + const v = parseVcard( + jcard([ + ["kind", {}, "text", "robot"], + ["org", {}, "text", "Solo Inc"], + ]), + ); + expect(v.kind).toBeUndefined(); + expect(v.org).toBe("Solo Inc"); + expect(v.orgUnits).toBeUndefined(); +}); + +test("parseVcard keeps PO box and extended address, and strips tel: URIs", () => { + const v = parseVcard( + jcard([ + ["adr", { cc: "nl" }, "text", ["PO Box 1", "Suite 5", "1 Main St", "Town", "", "1000", "NL"]], + ["tel", { type: "voice" }, "uri", "tel:+31.201234567"], + ]), + ); + expect(v.poBox).toBe("PO Box 1"); + expect(v.street).toEqual(["Suite 5", "1 Main St"]); + expect(v.countryCode).toBe("NL"); + expect(v.tel).toEqual(["+31.201234567"]); +}); diff --git a/src/rdap/vcard.ts b/src/rdap/vcard.ts new file mode 100644 index 0000000..fbdf790 --- /dev/null +++ b/src/rdap/vcard.ts @@ -0,0 +1,144 @@ +import { asString } from "../lib/text"; + +/** One property of a jCard (RFC 7095): [name, params, valueType, ...values]. */ +export interface VCardProp { + name: string; + params: Record; + valueType: string; + value: unknown; +} + +export type VCardKind = "individual" | "org" | "group" | "location"; + +const KINDS: readonly string[] = ["individual", "org", "group", "location"]; + +/** Flatten an RDAP "vcardArray" into a property list. Tolerates malformed input. */ +export function readJCard(vcardArray: unknown): VCardProp[] { + // vcardArray is ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Example"], ...]] + if (!Array.isArray(vcardArray) || vcardArray[0] !== "vcard" || !Array.isArray(vcardArray[1])) { + return []; + } + const out: VCardProp[] = []; + for (const raw of vcardArray[1] as unknown[]) { + if (!Array.isArray(raw) || typeof raw[0] !== "string") continue; + const params = raw[1] && typeof raw[1] === "object" ? (raw[1] as Record) : {}; + out.push({ + name: raw[0].toLowerCase(), + params, + valueType: typeof raw[2] === "string" ? raw[2].toLowerCase() : "text", + value: raw[3], + }); + } + return out; +} + +/** A jCard parameter (e.g. TYPE) as a lowercase string list; it may be a string or an array. */ +export function paramList(param: unknown): string[] { + const list = Array.isArray(param) ? param : param === undefined ? [] : [param]; + return list.filter((x) => typeof x === "string").map((x) => (x as string).toLowerCase()); +} + +/** Element `i` of a structured value, as a non-empty string. */ +export function part(value: unknown, i: number): string | undefined { + return Array.isArray(value) ? asString(value[i]) || undefined : undefined; +} + +/** Text value; `tel:` URIs (RFC 6350 allows TEL as uri) are reduced to the number. */ +function textValue(p: VCardProp): string | undefined { + const v = asString(p.value); + if (!v) return undefined; + return p.valueType === "uri" || /^tel:/i.test(v) ? v.replace(/^tel:/i, "") : v; +} + +export interface ParsedVCard { + fn?: string; + kind?: VCardKind; + /** First ORG level: the organization name */ + org?: string; + /** Remaining ORG levels (organizational units) */ + orgUnits?: string[]; + title?: string; + role?: string; + email?: string[]; + tel?: string[]; + fax?: string[]; + url?: string; + poBox?: string; + street?: string[]; + locality?: string; + region?: string; + postcode?: string; + country?: string; + countryCode?: string; +} + +const lines = (s: string | undefined) => (s ? s.split(/\r?\n/).filter(Boolean) : []); + +/** Extract the fields rdapper cares about from an RDAP "vcardArray". */ +export function parseVcard(vcardArray: unknown): ParsedVCard { + const out: ParsedVCard = {}; + for (const p of readJCard(vcardArray)) { + switch (p.name) { + case "fn": + out.fn ??= asString(p.value); + break; + case "kind": { + const k = asString(p.value)?.toLowerCase(); + if (k && KINDS.includes(k)) out.kind ??= k as VCardKind; + break; + } + case "org": { + if (out.org !== undefined) break; + // ORG is structured: a string, or [name, unit, unit, ...] + const levels = (Array.isArray(p.value) ? p.value : [p.value]) + .filter((x): x is string => typeof x === "string") + .map((x) => x.trim()) + .filter(Boolean); + if (levels.length) { + out.org = levels[0]; + if (levels.length > 1) out.orgUnits = levels.slice(1); + } + break; + } + case "title": + out.title ??= asString(p.value); + break; + case "role": + out.role ??= asString(p.value); + break; + case "email": { + const v = asString(p.value); + if (v) (out.email ??= []).push(v); + break; + } + case "tel": { + const v = textValue(p); + if (!v) break; + // TYPE may be a string or an array (e.g. "fax", ["work", "fax"]) + (paramList(p.params.type).includes("fax") ? (out.fax ??= []) : (out.tel ??= [])).push(v); + break; + } + case "url": + out.url ??= asString(p.value); + break; + case "adr": { + if (!Array.isArray(p.value) || out.country !== undefined || out.locality !== undefined) { + break; + } + // [postOfficeBox, extendedAddress, street, locality, region, postalCode, country] + out.poBox = part(p.value, 0); + const street = [...lines(part(p.value, 1)), ...lines(part(p.value, 2))]; + out.street = street.length ? street : undefined; + out.locality = part(p.value, 3); + out.region = part(p.value, 4); + out.postcode = part(p.value, 5); + out.country = part(p.value, 6); + // RFC 8605: ISO 3166-1 alpha-2 code lives in the "cc" parameter + const cc = asString(p.params.cc); + if (cc) out.countryCode = cc.toUpperCase(); + break; + } + } + } + return out; +} diff --git a/src/types.ts b/src/types.ts index bacb194..d81e6b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,13 +23,17 @@ export interface RegistrarInfo { email?: string; /** Registrar contact phone number */ phone?: string; - /** Registrar street address lines */ + /** Street address lines (RDAP only) */ street?: string[]; + /** City or locality (RDAP only) */ city?: string; + /** State, province, or region (RDAP only) */ state?: string; + /** Postal or ZIP code (RDAP only) */ postalCode?: string; + /** Country name (RDAP only) */ country?: string; - /** ISO 3166-1 alpha-2 country code, when provided */ + /** ISO 3166-1 alpha-2 country code, from the vCard `cc` parameter or resolved from `country` (RDAP only) */ countryCode?: string; } @@ -41,6 +45,7 @@ export interface RegistrarInfo { * varies by TLD, registrar, and privacy policies (GDPR, WHOIS privacy services). */ export interface Contact { + /** Role this contact plays for the domain */ type: | "registrant" | "admin" @@ -50,19 +55,39 @@ export interface Contact { | "registrar" | "reseller" | "unknown"; + /** Contact name as reported: usually a person, sometimes an organization */ name?: string; + /** vCard KIND, when the registry provides it (RDAP only) */ + kind?: "individual" | "org" | "group" | "location"; + /** Organization name (the first vCard ORG level in RDAP) */ organization?: string; + /** Organizational units below `organization` (vCard ORG levels 2+, RDAP only) */ + organizationUnits?: string[]; + /** Job title (vCard TITLE, RDAP only) */ + title?: string; + /** Role within the organization (vCard ROLE, RDAP only) */ + role?: string; + /** Email address; an array when the source lists several. Placeholder text is dropped. */ email?: string | string[]; + /** Voice phone number; an array when the source lists several. Placeholder text is dropped. */ phone?: string | string[]; + /** Fax number; an array when the source lists several */ fax?: string | string[]; + /** Post office box (RDAP only) */ + poBox?: string; + /** Street address lines */ street?: string[]; + /** City or locality */ city?: string; + /** State, province, or region */ state?: string; + /** Postal or ZIP code */ postalCode?: string; + /** Country name; filled from `countryCode` when only the code is given */ country?: string; - /** ISO 3166-1 alpha-2 country code */ + /** ISO 3166-1 alpha-2 country code; filled from `country` when it can be resolved */ countryCode?: string; - /** True when some of this contact's data was redacted, withheld, or replaced by a placeholder */ + /** True when any of this contact's data was redacted, withheld, or replaced by a placeholder */ redacted?: boolean; } @@ -111,7 +136,7 @@ export interface Nameserver { * @see {@link https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en ICANN EPP Status Codes} */ export interface StatusEvent { - /** Normalized status code (e.g., "clientTransferProhibited") */ + /** Status code (e.g., "clientTransferProhibited") */ status: string; /** Human-readable description of the status, if available */ description?: string; @@ -145,43 +170,49 @@ export interface StatusEvent { * ``` */ export interface DomainRecord { - /** Normalized domain name */ + /** Domain name (Unicode form when available, otherwise as queried) */ domain: string; - /** Terminal TLD */ + /** Top-level domain the lookup was routed by (e.g., "com") */ tld: string; /** Whether the domain is registered */ isRegistered: boolean; /** Whether the domain is internationalized (IDN) */ isIDN?: boolean; - /** Unicode name */ + /** Unicode (IDN) form of the name, when provided */ unicodeName?: string; - /** Punycode name */ + /** Punycode (ASCII) form of the name, when provided */ punycodeName?: string; - /** Registry operator */ + /** Registry operator name (rarely available) */ registry?: string; - /** Registrar */ + /** Registrar responsible for the registration */ registrar?: RegistrarInfo; - /** Reseller (if applicable) */ + /** Reseller name, if the source provides one */ reseller?: string; - /** EPP status codes */ + /** EPP status codes and registry-specific statuses */ statuses?: StatusEvent[]; - /** Creation date in ISO 8601 */ + /** When the domain was registered (ISO 8601) */ creationDate?: string; - /** Updated date in ISO 8601 */ + /** When the record was last changed (ISO 8601) */ updatedDate?: string; - /** Expiration date in ISO 8601 */ + /** When the registration expires (ISO 8601) */ expirationDate?: string; - /** Deletion date in ISO 8601 */ + /** When the domain was or will be deleted (ISO 8601), if reported (RDAP only) */ deletionDate?: string; - /** Transfer lock */ + /** Whether a transfer-prohibited status is set (client or server) */ transferLock?: boolean; /** DNSSEC data (if available) */ dnssec?: { + /** Whether the delegation is signed */ enabled: boolean; + /** DS records published for the delegation */ dsRecords?: Array<{ + /** Key tag */ keyTag?: number; + /** DNSSEC algorithm number */ algorithm?: number; + /** Digest type number */ digestType?: number; + /** Digest, as a hex string */ digest?: string; }>; }; @@ -189,21 +220,21 @@ export interface DomainRecord { nameservers?: Nameserver[]; /** Contacts (registrant, admin, tech, billing, abuse, etc.) */ contacts?: Contact[]; - /** Best guess as to whether registrant is redacted based on keywords */ + /** Best guess that the registrant is hidden behind a privacy service or redacted, from name/organization phrases or RFC 9537 redactions */ privacyEnabled?: boolean; /** RFC 9537 redaction metadata reported by RDAP, if any */ redactions?: Redaction[]; - /** Authoritative WHOIS queried (if any) */ + /** WHOIS server that answered, or the RDAP `port43` pointer (if any) */ whoisServer?: string; /** RDAP base URLs tried */ rdapServers?: string[]; - /** Raw RDAP JSON */ + /** Raw RDAP JSON (only with `includeRaw`) */ rawRdap?: unknown; - /** Raw WHOIS text (last authoritative) */ + /** Raw WHOIS text from the last authoritative server (only with `includeRaw`) */ rawWhois?: string; - /** Which source produced data */ + /** Which source produced the data */ source: LookupSource; - /** Warnings generated during lookup */ + /** Non-fatal warnings from the lookup (currently WHOIS referral problems, e.g. a skipped unsafe host or a referral that returned no data) */ warnings?: string[]; } @@ -419,6 +450,8 @@ export interface LookupResult { /** * Machine-readable reason a lookup (or one attempt within it) failed. * + * - `invalid_input`: the input does not look like a domain name + * - `invalid_tld`: the TLD is not valid * - `timeout`: any timeout, including the overall `deadlineMs` * - `aborted`: the caller's `AbortSignal` fired * - `connect_failed`: network-level failure (ECONNREFUSED, ECONNRESET, ENOTFOUND, ...) @@ -428,7 +461,9 @@ export interface LookupResult { * - `rate_limited`: the server throttled the query (RDAP 429, or a WHOIS throttle notice) * - `blocked`: the WHOIS server refuses this client outright (retrying will not help) * - `unparseable`: WHOIS replied with text that is neither an availability notice nor a record + * - `no_data`: a WHOIS server accepted the connection but closed it without sending anything * - `unsupported_runtime`: WHOIS needs `node:net`, which this runtime lacks + * - `unknown`: any failure not covered above */ export type LookupErrorCode = | "invalid_input" @@ -448,12 +483,17 @@ export type LookupErrorCode = /** One network operation performed during a lookup. */ export interface LookupAttempt { + /** Step of the lookup this attempt belongs to (IANA bootstrap fetch, RDAP query, RDAP link follow, IANA WHOIS discovery, WHOIS query) */ phase: "rdap_bootstrap" | "rdap" | "rdap_link" | "iana" | "whois"; /** RDAP base/link URL or WHOIS host */ server: string; + /** Whether this attempt succeeded */ ok: boolean; + /** How long the attempt took, in milliseconds */ durationMs: number; + /** Machine-readable failure reason, present when ok is false */ errorCode?: LookupErrorCode; + /** Error message, present when ok is false */ error?: string; /** `rate_limited` RDAP failures only: the server's `Retry-After`, in milliseconds */ retryAfterMs?: number;