mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15:31 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
+75
-21
@@ -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 = !!(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user