mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15:31 -04:00
feat: add country to ContactField, drop placeholder countries, and make finalizeContact idempotent
- Extend `ContactField` union and `REPORTED_FIELDS`/`ADR_INDEX_FIELDS`/`VCARD_PROPERTY_FIELDS` to include `"country"` so RFC 9537 redactions targeting the country field and vCard `adr[6]` are now tracked in `redactedFields` - Drop placeholder country values (`"N/A"`, `"REDACTED FOR PRIVACY"`, etc.) in `finalizeContact`, but only after confirming the value isn't a real country code or name (e.g. `"NA"` resolves to Namibia and is kept) - Seed `redactedFields` and `redacted` from the contact's existing values at the start of `finalizeContact` so calling it twice on the same contact produces identical output (idempotent) - Expand `PLACEHOLDER_VALUE_PATTERNS` to match bare dot sequences (`".."`) and whole-value-only phrases (`"Not Available"`, `"Not Published"`, `"Not Public"`, `"No Data"`) without risk of false-positives on substrings like `"No Data Corp"` - Export `finalizeContact` and `isPrivacyContact` from the package entry point
This commit is contained in:
@@ -571,6 +571,7 @@ interface DomainRecord {
|
||||
| "state"
|
||||
| "postalCode"
|
||||
| "poBox"
|
||||
| "country"
|
||||
>; // fields that were redacted (and are therefore absent)
|
||||
privacyService?: boolean; // name/organization is a privacy/proxy service (text kept), not a redaction notice
|
||||
}>;
|
||||
|
||||
@@ -235,6 +235,7 @@ export async function isRegistered(domain: string, opts?: LookupOptions): Promis
|
||||
export const lookupDomain = lookup;
|
||||
|
||||
export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain";
|
||||
export { finalizeContact, isPrivacyContact } from "./lib/contacts";
|
||||
export { resolveCountry } from "./lib/countries";
|
||||
export { isPlaceholderValue, isPrivacyName } from "./lib/privacy";
|
||||
export type * from "./types";
|
||||
|
||||
@@ -57,6 +57,7 @@ test("redactionFields maps RFC 9537 names to contact fields", () => {
|
||||
expect(redactionFields("Tech Phone Ext")).toEqual(["phone"]);
|
||||
expect(redactionFields("Registrant Street")).toEqual(["street"]);
|
||||
expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]);
|
||||
expect(redactionFields("Registrant Country")).toEqual(["country"]);
|
||||
});
|
||||
|
||||
test("redactionFields falls back to the vCard property in prePath", () => {
|
||||
@@ -67,6 +68,7 @@ test("redactionFields falls back to the vCard property in prePath", () => {
|
||||
expect(redactionFields("x", path("org"))).toEqual(["organization"]);
|
||||
expect(redactionFields("x", path("tel"))).toEqual(["phone"]);
|
||||
expect(redactionFields("x", path("adr", "[3][3]"))).toEqual(["city"]);
|
||||
expect(redactionFields("x", path("adr", "[3][6]"))).toEqual(["country"]);
|
||||
expect(redactionFields("x", path("adr"))).toContain("street");
|
||||
expect(redactionFields("x", "$.entities[0]")).toEqual([]);
|
||||
// The name wins when it identifies fields
|
||||
@@ -98,3 +100,42 @@ test("package entry exports the contact predicates", () => {
|
||||
countryCode: "DE",
|
||||
});
|
||||
});
|
||||
|
||||
test("finalizeContact drops placeholder countries but keeps real ones", () => {
|
||||
const na = finalizeContact({ type: "registrant", country: "N/A" });
|
||||
expect(na.country).toBeUndefined();
|
||||
expect(na.redactedFields).toEqual(["country"]);
|
||||
expect(na.redacted).toBe(true);
|
||||
|
||||
const rp = finalizeContact({ type: "registrant", country: "REDACTED FOR PRIVACY" });
|
||||
expect(rp.country).toBeUndefined();
|
||||
expect(rp.redactedFields).toEqual(["country"]);
|
||||
|
||||
const namibia = finalizeContact({ type: "registrant", country: "NA" });
|
||||
expect(namibia.countryCode).toBe("NA");
|
||||
expect(namibia.country).toBe("Namibia");
|
||||
expect(namibia.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
test("finalizeContact treats bare 'not available' style values as placeholders", () => {
|
||||
for (const v of ["Not Available", "Not Published", "Not Public", "No Data", ".."]) {
|
||||
expect(isPlaceholderValue(v)).toBe(true);
|
||||
}
|
||||
expect(isPlaceholderValue("No Data Corp")).toBe(false);
|
||||
});
|
||||
|
||||
test("finalizeContact is idempotent", () => {
|
||||
const once = finalizeContact({
|
||||
type: "registrant",
|
||||
name: "Domains By Proxy, LLC",
|
||||
email: "REDACTED FOR PRIVACY",
|
||||
country: "N/A",
|
||||
});
|
||||
const twice = finalizeContact(structuredClone(once));
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
test("finalizeContact and isPrivacyContact are exported", () => {
|
||||
expect(api.finalizeContact).toBe(finalizeContact);
|
||||
expect(api.isPrivacyContact({ type: "registrant", privacyService: true })).toBe(true);
|
||||
});
|
||||
|
||||
+24
-5
@@ -1,5 +1,5 @@
|
||||
import type { Contact, ContactField } from "../types";
|
||||
import { resolveCountry } from "./countries";
|
||||
import { countryCodeFromName, resolveCountry } from "./countries";
|
||||
import { isPlaceholderValue, isPrivacyName } from "./privacy";
|
||||
|
||||
function cleanValue(
|
||||
@@ -32,6 +32,9 @@ const CLEANED_FIELDS = [
|
||||
"poBox",
|
||||
] as const satisfies readonly ContactField[];
|
||||
|
||||
// Order in which `redactedFields` is reported: the cleaned fields, then `country` (resolved separately).
|
||||
const REPORTED_FIELDS: readonly ContactField[] = [...CLEANED_FIELDS, "country"];
|
||||
|
||||
// Secondary fields: cleaned the same way but not reported (no `ContactField` for them).
|
||||
const CLEANED_EXTRA = ["organizationUnits", "title", "role"] as const;
|
||||
|
||||
@@ -42,7 +45,7 @@ const VCARD_PROPERTY_FIELDS: Record<string, ContactField[]> = {
|
||||
org: ["organization"],
|
||||
email: ["email"],
|
||||
tel: ["phone"],
|
||||
adr: ["poBox", "street", "city", "state", "postalCode"],
|
||||
adr: ["poBox", "street", "city", "state", "postalCode", "country"],
|
||||
};
|
||||
// Position within an `adr` value (`[3][N]`) to the field it holds.
|
||||
const ADR_INDEX_FIELDS: Record<number, ContactField> = {
|
||||
@@ -51,6 +54,7 @@ const ADR_INDEX_FIELDS: Record<number, ContactField> = {
|
||||
3: "city",
|
||||
4: "state",
|
||||
5: "postalCode",
|
||||
6: "country",
|
||||
};
|
||||
|
||||
function fieldsFromName(text: string): ContactField[] {
|
||||
@@ -66,6 +70,7 @@ function fieldsFromName(text: string): ContactField[] {
|
||||
if (/state|province|region/.test(t)) out.push("state");
|
||||
if (/postal|post code|postcode|zip/.test(t)) out.push("postalCode");
|
||||
if (/\bpo box\b|\bpobox\b/.test(t)) out.push("poBox");
|
||||
if (/country/.test(t)) out.push("country");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -101,8 +106,12 @@ export function finalizeContact(
|
||||
contact: Contact,
|
||||
redactedHint: boolean | ContactField[] = false,
|
||||
): Contact {
|
||||
const redactedFields = new Set<ContactField>();
|
||||
let redacted = redactedHint === true || (Array.isArray(redactedHint) && redactedHint.length > 0);
|
||||
// Seeded from the contact itself so re-finalizing an already-cleaned contact keeps its signals.
|
||||
const redactedFields = new Set<ContactField>(contact.redactedFields);
|
||||
let redacted =
|
||||
!!contact.redacted ||
|
||||
redactedHint === true ||
|
||||
(Array.isArray(redactedHint) && redactedHint.length > 0);
|
||||
if (Array.isArray(redactedHint)) for (const f of redactedHint) redactedFields.add(f);
|
||||
|
||||
const record = contact as unknown as Record<string, string | string[] | undefined>;
|
||||
@@ -123,12 +132,22 @@ export function finalizeContact(
|
||||
redacted = true;
|
||||
}
|
||||
|
||||
// A placeholder country ("N/A", "REDACTED FOR PRIVACY") is dropped, but only when it isn't a
|
||||
// real country: two-letter values like "NA" (Namibia) must resolve first.
|
||||
if (
|
||||
contact.country &&
|
||||
isPlaceholderValue(contact.country) &&
|
||||
!countryCodeFromName(contact.country)
|
||||
) {
|
||||
contact.country = undefined;
|
||||
redactedFields.add("country");
|
||||
}
|
||||
const { country, countryCode } = resolveCountry(contact.country, contact.countryCode);
|
||||
contact.country = country;
|
||||
contact.countryCode = countryCode;
|
||||
|
||||
if (redactedFields.size) {
|
||||
contact.redactedFields = CLEANED_FIELDS.filter((f) => redactedFields.has(f));
|
||||
contact.redactedFields = REPORTED_FIELDS.filter((f) => redactedFields.has(f));
|
||||
redacted = true;
|
||||
}
|
||||
if (redacted) contact.redacted = true;
|
||||
|
||||
+3
-1
@@ -84,7 +84,9 @@ const PLACEHOLDER_VALUE_PATTERNS = [
|
||||
/\bcontact (?:the )?registrar\b/i,
|
||||
/\bnot applicable\b/i,
|
||||
/select request email form/i,
|
||||
/^(?:-+|n\/a|na|none|null|undefined|unknown)$/i,
|
||||
/^(?:-+|\.+|n\/a|na|none|null|undefined|unknown)$/i,
|
||||
// Whole-value only: as substrings these could match real text (and would flag names as privacy)
|
||||
/^(?:not available|not published|not public|no data)$/i,
|
||||
];
|
||||
|
||||
/** True when a contact field value (email, phone, ...) is a placeholder rather than real data. */
|
||||
|
||||
+2
-1
@@ -109,7 +109,8 @@ export type ContactField =
|
||||
| "city"
|
||||
| "state"
|
||||
| "postalCode"
|
||||
| "poBox";
|
||||
| "poBox"
|
||||
| "country";
|
||||
|
||||
/**
|
||||
* An RFC 9537 redaction entry describing a field the registry withheld.
|
||||
|
||||
Reference in New Issue
Block a user