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;
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)
+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;
// 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<Json>(merged.redacted), ...toArray<Json>(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;
+132
View File
@@ -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",
});
});
+77 -23
View File
@@ -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<string, unknown>;
@@ -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;
}
+28
View File
@@ -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 */
+2
View File
@@ -370,6 +370,8 @@ function collectContacts(map: Record<string, string[]>): 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,
});
}
}