refactor: extract vCard parser into its own module and expand parsed fields

- 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
This commit is contained in:
2026-09-19 23:20:15 -04:00
parent 3595e5b42c
commit 992c54ad28
6 changed files with 301 additions and 95 deletions
+18
View File
@@ -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" });
});
+8 -71
View File
@@ -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<string, unknown>;
@@ -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>, 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;
}
+62
View File
@@ -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"]);
});
+144
View File
@@ -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<string, unknown>;
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<string, unknown>) : {};
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;
}
+64 -24
View File
@@ -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;