From 1ab34f4561e3563b26d5fb48ed5df7599454b3a8 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 20 Sep 2026 11:04:47 -0400 Subject: [PATCH] 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 --- README.md | 1 + src/index.ts | 1 + src/lib/contacts.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++ src/lib/contacts.ts | 29 +++++++++++++++++++++++----- src/lib/privacy.ts | 4 +++- src/types.ts | 3 ++- 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e789326..23d8da3 100644 --- a/README.md +++ b/README.md @@ -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 }>; diff --git a/src/index.ts b/src/index.ts index 1ef2eb0..bf5dffe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/lib/contacts.test.ts b/src/lib/contacts.test.ts index ae029b2..934d2b3 100644 --- a/src/lib/contacts.test.ts +++ b/src/lib/contacts.test.ts @@ -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); +}); diff --git a/src/lib/contacts.ts b/src/lib/contacts.ts index e1fb4e9..be1a2ff 100644 --- a/src/lib/contacts.ts +++ b/src/lib/contacts.ts @@ -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 = { 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 = { @@ -51,6 +54,7 @@ const ADR_INDEX_FIELDS: Record = { 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(); - 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(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; @@ -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; diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index b2f9136..ab3c111 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -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. */ diff --git a/src/types.ts b/src/types.ts index 561e846..ef0b4c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,7 +109,8 @@ export type ContactField = | "city" | "state" | "postalCode" - | "poBox"; + | "poBox" + | "country"; /** * An RFC 9537 redaction entry describing a field the registry withheld.