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:
2026-09-20 11:04:47 -04:00
parent 0fea5e0a05
commit 1ab34f4561
6 changed files with 72 additions and 7 deletions
+1
View File
@@ -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";
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -109,7 +109,8 @@ export type ContactField =
| "city"
| "state"
| "postalCode"
| "poBox";
| "poBox"
| "country";
/**
* An RFC 9537 redaction entry describing a field the registry withheld.