feat: add RFC 9537 redaction support, vCard address/fax parsing, and registrar address fields

- Parse the top-level RDAP `redacted` array (RFC 9537) into a new `redactions` field on `DomainRecord`; set `privacyEnabled` when any redaction references "registrant"
- Expose `street`, `city`, `state`, `postalCode`, `country`, and `countryCode` on both `RegistrarInfo` and `Contact`; extract ISO 3166-1 alpha-2 from the vCard `cc` parameter (RFC 8605)
- Split `tel` entries by fax type parameter (`"fax"` / `["work","fax"]`) into separate `tel` and `fax` lists; collect multiple email addresses as an array, collapsing singletons to a plain string
- Prefer an exact `registration` event match over `reregistration` when extracting `creationDate` (RFC 9083)
- Merge `redacted` arrays in `mergeRdapDocs`, deduplicating by JSON identity
- Split `adr` street on `\r?\n` only (not commas) to avoid splitting inline comma-separated addresses
This commit is contained in:
2026-09-19 23:01:30 -04:00
parent e77fc7f4ed
commit 386006dc61
6 changed files with 261 additions and 24 deletions
+15 -1
View File
@@ -508,6 +508,12 @@ interface DomainRecord {
url?: string; url?: string;
email?: string; email?: string;
phone?: 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; reseller?: string;
statuses?: Array<{ statuses?: Array<{
@@ -549,7 +555,15 @@ interface DomainRecord {
country?: string; country?: string;
countryCode?: 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) whoisServer?: string; // authoritative WHOIS queried (if any)
rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links) rdapServers?: string[]; // RDAP URLs tried (bootstrap bases and related/entity links)
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw) rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
+7
View File
@@ -43,6 +43,13 @@ export function mergeRdapDocs(baseDoc: unknown, others: unknown[]): unknown {
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 // port43 (authoritative WHOIS): prefer existing; fill if missing
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43; 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<Json>(merged.redacted), ...toArray<Json>(cur.redacted)],
(r) => JSON.stringify(r),
);
}
// remarks: concat simple strings if present // remarks: concat simple strings if present
const mergedRemarks = (merged as { remarks?: Json[] }).remarks; const mergedRemarks = (merged as { remarks?: Json[] }).remarks;
const curRemarks = (cur as { remarks?: Json[] }).remarks; const curRemarks = (cur as { remarks?: Json[] }).remarks;
+132
View File
@@ -135,3 +135,135 @@ test("normalizeRdap treats release-pending statuses as not registered", () => {
); );
expect(active.isRegistered).toBe(true); 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",
});
});
+75 -21
View File
@@ -1,7 +1,7 @@
import { toISO } from "../lib/dates"; import { toISO } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy"; import { isPrivacyName } from "../lib/privacy";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; 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<string, unknown>; type RdapDoc = Record<string, unknown>;
@@ -44,12 +44,16 @@ export function normalizeRdap(
// 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(doc.entities as unknown); 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 registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = !!( const privacyEnabled =
!!(
registrant && registrant &&
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) ([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. // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
const statuses = Array.isArray(doc.status) const statuses = Array.isArray(doc.status)
@@ -81,10 +85,14 @@ export function normalizeRdap(
const events: RdapEvent[] = Array.isArray(doc.events) const events: RdapEvent[] = Array.isArray(doc.events)
? (doc.events as unknown[] as RdapEvent[]) ? (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) => const byAction = (action: string) =>
events.find( events.find((e) => actionOf(e).toLowerCase() === action) ??
(e) => typeof e?.eventAction === "string" && e.eventAction.toLowerCase().includes(action), events.find((e) => {
); const a = actionOf(e).toLowerCase();
return a.includes(action) && a !== "reregistration";
});
const creationDate = toISO( const creationDate = toISO(
asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate), asDateLike(byAction("registration")?.eventDate) ?? asDateLike(doc.registrationDate),
); );
@@ -135,6 +143,7 @@ export function normalizeRdap(
: undefined, : undefined,
contacts, contacts,
privacyEnabled: privacyEnabled ? true : undefined, privacyEnabled: privacyEnabled ? true : undefined,
redactions,
whoisServer, whoisServer,
rdapServers: rdapServersTried, rdapServers: rdapServersTried,
rawRdap: includeRaw ? rdap : undefined, rawRdap: includeRaw ? rdap : undefined,
@@ -146,6 +155,29 @@ export function normalizeRdap(
return record; 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 { function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
if (!Array.isArray(entities)) return undefined; if (!Array.isArray(entities)) return undefined;
for (const ent of entities) { 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, name: v.fn || v.org || asString((ent as RdapDoc)?.handle) || undefined,
ianaId: asString(ianaId), ianaId: asString(ianaId),
url: v.url ?? undefined, url: v.url ?? undefined,
email: v.email ?? undefined, email: v.email?.[0],
phone: v.tel ?? undefined, phone: v.tel?.[0],
street: v.street,
city: v.locality,
state: v.region,
postalCode: v.postcode,
country: v.country,
countryCode: v.countryCode,
}; };
} }
return undefined; return undefined;
@@ -195,9 +233,9 @@ function extractContacts(entities: unknown): Contact[] | undefined {
type: roleKey, type: roleKey,
name: v.fn, name: v.fn,
organization: v.org, organization: v.org,
email: v.email, email: single(v.email),
phone: v.tel, phone: single(v.tel),
fax: v.fax, fax: single(v.fax),
street: v.street, street: v.street,
city: v.locality, city: v.locality,
state: v.region, state: v.region,
@@ -209,12 +247,18 @@ function extractContacts(entities: unknown): Contact[] | undefined {
return out.length ? out : 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 { interface ParsedVCard {
fn?: string; fn?: string;
org?: string; org?: string;
email?: string; email?: string[];
tel?: string; tel?: string[];
fax?: string; fax?: string[];
url?: string; url?: string;
street?: string[]; street?: string[];
locality?: string; locality?: string;
@@ -242,28 +286,38 @@ function parseVcard(vcardArray: unknown): ParsedVCard {
case "org": 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; break;
case "email": case "email": {
out.email = asString(value); const v = asString(value);
if (v) (out.email ??= []).push(v);
break; 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; break;
}
case "url": case "url":
out.url = asString(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] ? 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.locality = asString(value[3]);
out.region = asString(value[4]); out.region = asString(value[4]);
out.postcode = asString(value[5]); out.postcode = asString(value[5]);
out.country = asString(value[6]); 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; break;
} }
} }
} }
// Best effort country code from country name (often omitted). Leaving undefined unless explicitly provided.
return out; return out;
} }
+28
View File
@@ -23,6 +23,14 @@ export interface RegistrarInfo {
email?: string; email?: string;
/** Registrar contact phone number */ /** Registrar contact phone number */
phone?: string; 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; 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. * DNS nameserver information.
* *
@@ -162,6 +188,8 @@ export interface DomainRecord {
contacts?: Contact[]; contacts?: Contact[];
/** Best guess as to whether registrant is redacted based on keywords */ /** Best guess as to whether registrant is redacted based on keywords */
privacyEnabled?: boolean; privacyEnabled?: boolean;
/** RFC 9537 redaction metadata reported by RDAP, if any */
redactions?: Redaction[];
/** Authoritative WHOIS queried (if any) */ /** Authoritative WHOIS queried (if any) */
whoisServer?: string; whoisServer?: string;
/** RDAP base URLs tried */ /** RDAP base URLs tried */
+2
View File
@@ -370,6 +370,8 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
state: state || undefined, state: state || undefined,
postalCode: postalCode || undefined, postalCode: postalCode || undefined,
country: country || 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,
}); });
} }
} }