rdapper 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ RDAP‑first domain registration lookups with WHOIS fallback. Produces a single,
4
4
 
5
5
  - RDAP discovery via IANA bootstrap (`https://data.iana.org/rdap/dns.json`)
6
6
  - WHOIS TCP 43 client with TLD discovery, registrar referral follow, and curated exceptions
7
- - Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, source metadata
7
+ - Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, privacy flag, source metadata
8
8
  - TypeScript types included; ESM‑only; no external HTTP client (uses global `fetch`)
9
9
 
10
10
  ### [🦉 See it in action on hoot.sh!](https://hoot.sh)
@@ -49,6 +49,10 @@ await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
49
49
  - `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS.
50
50
  - `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
51
51
  - `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
52
+ - `maxWhoisReferralHops?: number` – Maximum registrar WHOIS referral hops to follow (default `2`).
53
+ - `rdapFollowLinks?: boolean` – Follow related/entity RDAP links to enrich data (default `true`).
54
+ - `maxRdapLinkHops?: number` – Maximum RDAP related link hops to follow (default `2`).
55
+ - `rdapLinkRels?: string[]` – RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`).
52
56
  - `customBootstrapUrl?: string` – Override RDAP bootstrap URL.
53
57
  - `whoisHints?: Record<string, string>` – Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
54
58
  - `includeRaw?: boolean` – Include `rawRdap`/`rawWhois` in the returned record (default `false`).
@@ -113,6 +117,7 @@ interface DomainRecord {
113
117
  country?: string;
114
118
  countryCode?: string;
115
119
  }>;
120
+ privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
116
121
  whoisServer?: string; // authoritative WHOIS queried (if any)
117
122
  rdapServers?: string[]; // RDAP base URLs tried
118
123
  rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
@@ -144,10 +149,11 @@ interface DomainRecord {
144
149
  - RDAP
145
150
  - Discovers base URLs for the TLD via IANA’s RDAP bootstrap JSON.
146
151
  - Tries each base until one responds successfully; parses standard RDAP domain JSON.
152
+ - Optionally follows related/entity links to registrar RDAP resources and merges results (bounded by hop limits).
147
153
  - Normalizes registrar (from `entities`), contacts (vCard), nameservers (`ipAddresses`), events (created/changed/expiration), statuses, and DNSSEC (`secureDNS`).
148
154
  - WHOIS
149
155
  - Discovers the authoritative TLD WHOIS via `whois.iana.org` (TCP 43), with curated exceptions for tricky zones and public SLDs.
150
- - Queries the TLD WHOIS; if a registrar referral is present and `followWhoisReferral !== false`, follows one hop to the registrar WHOIS.
156
+ - Queries the TLD WHOIS and follows registrar referrals recursively up to `maxWhoisReferralHops` (unless disabled).
151
157
  - Normalizes common key/value variants across gTLD/ccTLD formats (dates, statuses, nameservers, contacts). Availability is inferred from common phrases (best‑effort heuristic).
152
158
 
153
159
  Timeouts are enforced per request using a simple race against `timeoutMs` (default 15s). All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS).
package/dist/index.d.ts CHANGED
@@ -32,21 +32,37 @@ interface StatusEvent {
32
32
  raw?: string;
33
33
  }
34
34
  interface DomainRecord {
35
+ /** Normalized domain name */
35
36
  domain: string;
37
+ /** Terminal TLD */
36
38
  tld: string;
39
+ /** Whether the domain is registered */
37
40
  isRegistered: boolean;
41
+ /** Whether the domain is internationalized (IDN) */
38
42
  isIDN?: boolean;
43
+ /** Unicode name */
39
44
  unicodeName?: string;
45
+ /** Punycode name */
40
46
  punycodeName?: string;
47
+ /** Registry operator */
41
48
  registry?: string;
49
+ /** Registrar */
42
50
  registrar?: RegistrarInfo;
51
+ /** Reseller (if applicable) */
43
52
  reseller?: string;
53
+ /** EPP status codes */
44
54
  statuses?: StatusEvent[];
55
+ /** Creation date in ISO 8601 */
45
56
  creationDate?: string;
57
+ /** Updated date in ISO 8601 */
46
58
  updatedDate?: string;
59
+ /** Expiration date in ISO 8601 */
47
60
  expirationDate?: string;
61
+ /** Deletion date in ISO 8601 */
48
62
  deletionDate?: string;
63
+ /** Transfer lock */
49
64
  transferLock?: boolean;
65
+ /** DNSSEC data (if available) */
50
66
  dnssec?: {
51
67
  enabled: boolean;
52
68
  dsRecords?: Array<{
@@ -56,24 +72,51 @@ interface DomainRecord {
56
72
  digest?: string;
57
73
  }>;
58
74
  };
75
+ /** Nameservers */
59
76
  nameservers?: Nameserver[];
77
+ /** Contacts (registrant, admin, tech, billing, abuse, etc.) */
60
78
  contacts?: Contact[];
79
+ /** Best guess as to whether registrant is redacted based on keywords */
80
+ privacyEnabled?: boolean;
81
+ /** Authoritative WHOIS queried (if any) */
61
82
  whoisServer?: string;
83
+ /** RDAP base URLs tried */
62
84
  rdapServers?: string[];
85
+ /** Raw RDAP JSON */
63
86
  rawRdap?: unknown;
87
+ /** Raw WHOIS text (last authoritative) */
64
88
  rawWhois?: string;
89
+ /** Which source produced data */
65
90
  source: LookupSource;
91
+ /** ISO 8601 timestamp at time of lookup */
66
92
  fetchedAt: string;
93
+ /** Warnings generated during lookup */
67
94
  warnings?: string[];
68
95
  }
69
96
  interface LookupOptions {
97
+ /** Total timeout budget */
70
98
  timeoutMs?: number;
99
+ /** Don't fall back to WHOIS */
71
100
  rdapOnly?: boolean;
101
+ /** Don't attempt RDAP */
72
102
  whoisOnly?: boolean;
103
+ /** Follow referral server (default true) */
73
104
  followWhoisReferral?: boolean;
105
+ /** Maximum registrar WHOIS referral hops (default 2) */
106
+ maxWhoisReferralHops?: number;
107
+ /** Follow RDAP related/entity links (default true) */
108
+ rdapFollowLinks?: boolean;
109
+ /** Maximum RDAP related link fetches (default 2) */
110
+ maxRdapLinkHops?: number;
111
+ /** RDAP link rels to consider (default ["related","entity","registrar","alternate"]) */
112
+ rdapLinkRels?: string[];
113
+ /** Override IANA bootstrap */
74
114
  customBootstrapUrl?: string;
115
+ /** Override/add authoritative WHOIS per TLD */
75
116
  whoisHints?: Record<string, string>;
117
+ /** Include rawRdap/rawWhois in results (default false) */
76
118
  includeRaw?: boolean;
119
+ /** Optional cancellation signal */
77
120
  signal?: AbortSignal;
78
121
  }
79
122
  interface LookupResult {
package/dist/index.js CHANGED
@@ -9,14 +9,14 @@ function toISO(dateLike) {
9
9
  const raw = String(dateLike).trim();
10
10
  if (!raw) return void 0;
11
11
  for (const re of [
12
- /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
13
- /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/,
12
+ /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
13
+ /^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
14
14
  /^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
15
15
  /^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
16
16
  ]) {
17
17
  const m = raw.match(re);
18
18
  if (!m) continue;
19
- const d = parseWithRegex(m, re);
19
+ const d = parseDateWithRegex(m, re);
20
20
  if (d) return toIsoFromDate(d);
21
21
  }
22
22
  const native = new Date(raw);
@@ -29,7 +29,7 @@ function toIsoFromDate(d) {
29
29
  return;
30
30
  }
31
31
  }
32
- function parseWithRegex(m, _re) {
32
+ function parseDateWithRegex(m, _re) {
33
33
  const monthMap = {
34
34
  jan: 0,
35
35
  feb: 1,
@@ -46,8 +46,16 @@ function parseWithRegex(m, _re) {
46
46
  };
47
47
  try {
48
48
  if (m[0].includes(":")) {
49
- const [_$1, y, mo, d, hh, mm, ss] = m;
50
- return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss)));
49
+ const [_$1, y, mo, d, hh, mm, ss, offH, offM] = m;
50
+ let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
51
+ if (offH) {
52
+ const sign = offH.startsWith("-") ? -1 : 1;
53
+ const hours = Math.abs(Number(offH));
54
+ const minutes = offM ? Number(offM) : 0;
55
+ const offsetMs = sign * (hours * 60 + minutes) * 60 * 1e3;
56
+ dt -= offsetMs;
57
+ }
58
+ return new Date(dt);
51
59
  }
52
60
  if (m[0].includes("-")) {
53
61
  const [_$1, dd$1, monStr$1, yyyy$1] = m;
@@ -167,6 +175,149 @@ async function fetchRdapDomain(domain, baseUrl, options) {
167
175
  };
168
176
  }
169
177
 
178
+ //#endregion
179
+ //#region src/rdap/links.ts
180
+ /** Extract candidate RDAP link URLs from an RDAP document. */
181
+ function extractRdapRelatedLinks(doc, opts) {
182
+ const rels = (opts?.rdapLinkRels?.length ? opts.rdapLinkRels : [
183
+ "related",
184
+ "entity",
185
+ "registrar",
186
+ "alternate"
187
+ ]).map((r) => r.toLowerCase());
188
+ const d = doc ?? {};
189
+ const arr = Array.isArray(d?.links) ? d.links : [];
190
+ const out = [];
191
+ for (const link of arr) {
192
+ const rel = String(link.rel || "").toLowerCase();
193
+ const type = String(link.type || "").toLowerCase();
194
+ if (!rels.includes(rel)) continue;
195
+ if (type && !/application\/rdap\+json/i.test(type)) continue;
196
+ const url = link.href || link.value;
197
+ if (url && /^https?:\/\//i.test(url)) out.push(url);
198
+ }
199
+ return Array.from(new Set(out));
200
+ }
201
+
202
+ //#endregion
203
+ //#region src/rdap/merge.ts
204
+ /** Merge RDAP documents with a conservative, additive strategy. */
205
+ function mergeRdapDocs(baseDoc, others) {
206
+ const merged = { ...baseDoc };
207
+ for (const doc of others) {
208
+ const cur = doc ?? {};
209
+ merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
210
+ merged.events = uniqBy([...toArray(merged.events), ...toArray(cur.events)], (e) => `${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`);
211
+ merged.nameservers = uniqBy([...toArray(merged.nameservers), ...toArray(cur.nameservers)], (n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`);
212
+ merged.entities = uniqBy([...toArray(merged.entities), ...toArray(cur.entities)], (e) => `${String(e?.handle ?? "").toLowerCase()}|${String(JSON.stringify(e?.roles || [])).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`);
213
+ if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
214
+ if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
215
+ const mergedRemarks = merged.remarks;
216
+ const curRemarks = cur.remarks;
217
+ if (Array.isArray(mergedRemarks) || Array.isArray(curRemarks)) {
218
+ const a = toArray(mergedRemarks);
219
+ const b = toArray(curRemarks);
220
+ merged.remarks = [...a, ...b];
221
+ }
222
+ }
223
+ return merged;
224
+ }
225
+ /** Fetch and merge RDAP related documents up to a hop limit. */
226
+ async function fetchAndMergeRdapRelated(domain, baseDoc, opts) {
227
+ const tried = [];
228
+ if (opts?.rdapFollowLinks === false) return {
229
+ merged: baseDoc,
230
+ serversTried: tried
231
+ };
232
+ const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
233
+ if (maxHops === 0) return {
234
+ merged: baseDoc,
235
+ serversTried: tried
236
+ };
237
+ const visited = /* @__PURE__ */ new Set();
238
+ let current = baseDoc;
239
+ let hops = 0;
240
+ while (hops < maxHops) {
241
+ const nextBatch = extractRdapRelatedLinks(current, { rdapLinkRels: opts?.rdapLinkRels }).filter((u) => !visited.has(u));
242
+ if (nextBatch.length === 0) break;
243
+ const fetchedDocs = [];
244
+ for (const url of nextBatch) {
245
+ visited.add(url);
246
+ try {
247
+ const { json } = await fetchRdapUrl(url, opts);
248
+ tried.push(url);
249
+ const ldh = String(json?.ldhName ?? "").toLowerCase();
250
+ const uni = String(json?.unicodeName ?? "").toLowerCase();
251
+ if (ldh && !sameDomain(ldh, domain)) continue;
252
+ if (uni && !sameDomain(uni, domain)) continue;
253
+ fetchedDocs.push(json);
254
+ } catch {}
255
+ }
256
+ if (fetchedDocs.length === 0) break;
257
+ current = mergeRdapDocs(current, fetchedDocs);
258
+ hops += 1;
259
+ }
260
+ return {
261
+ merged: current,
262
+ serversTried: tried
263
+ };
264
+ }
265
+ async function fetchRdapUrl(url, options) {
266
+ const res = await withTimeout(fetch(url, {
267
+ method: "GET",
268
+ headers: { accept: "application/rdap+json, application/json" },
269
+ signal: options?.signal
270
+ }), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP link fetch timeout");
271
+ if (!res.ok) {
272
+ const bodyText = await res.text();
273
+ throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
274
+ }
275
+ const json = await res.json();
276
+ return {
277
+ url,
278
+ json
279
+ };
280
+ }
281
+ function toArray(val) {
282
+ return Array.isArray(val) ? val : [];
283
+ }
284
+ function toStringArray(val) {
285
+ return Array.isArray(val) ? val.map((v) => String(v)) : [];
286
+ }
287
+ function uniqStrings(arr) {
288
+ return Array.from(new Set(arr));
289
+ }
290
+ function uniqBy(arr, key) {
291
+ const seen = /* @__PURE__ */ new Set();
292
+ const out = [];
293
+ for (const item of arr) {
294
+ const k = key(item);
295
+ if (seen.has(k)) continue;
296
+ seen.add(k);
297
+ out.push(item);
298
+ }
299
+ return out;
300
+ }
301
+ function sameDomain(a, b) {
302
+ return a.toLowerCase() === b.toLowerCase();
303
+ }
304
+
305
+ //#endregion
306
+ //#region src/lib/privacy.ts
307
+ const PRIVACY_NAME_KEYWORDS = [
308
+ "redacted",
309
+ "privacy",
310
+ "private",
311
+ "withheld",
312
+ "not disclosed",
313
+ "protected",
314
+ "protection"
315
+ ];
316
+ function isPrivacyName(value) {
317
+ const v = value.toLowerCase();
318
+ return PRIVACY_NAME_KEYWORDS.some((k) => v.includes(k));
319
+ }
320
+
170
321
  //#endregion
171
322
  //#region src/lib/text.ts
172
323
  function uniq(arr) {
@@ -247,6 +398,8 @@ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, i
247
398
  return n;
248
399
  }).filter((n) => !!n.host) : void 0;
249
400
  const contacts = extractContacts(doc.entities);
401
+ const registrant = contacts?.find((c) => c.type === "registrant");
402
+ const privacyEnabled = !!(registrant && [registrant.name, registrant.organization].filter(Boolean).some(isPrivacyName));
250
403
  const statuses = Array.isArray(doc.status) ? doc.status.filter((s) => typeof s === "string").map((s) => ({
251
404
  status: s,
252
405
  raw: s
@@ -291,6 +444,7 @@ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, i
291
444
  host: n.host.toLowerCase()
292
445
  }))) : void 0,
293
446
  contacts,
447
+ privacyEnabled: privacyEnabled ? true : void 0,
294
448
  whoisServer,
295
449
  rdapServers: rdapServersTried,
296
450
  rawRdap: includeRaw ? rdap : void 0,
@@ -592,18 +746,26 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
592
746
  "domain registration date",
593
747
  "domain create date",
594
748
  "created",
595
- "registered"
749
+ "registered",
750
+ "domain record activated"
596
751
  ]);
597
752
  const updatedDate = anyValue(map, [
598
753
  "updated date",
599
754
  "last updated",
600
755
  "last modified",
601
- "modified"
756
+ "modified",
757
+ "domain record last updated"
602
758
  ]);
603
759
  const expirationDate = anyValue(map, [
604
760
  "registry expiry date",
761
+ "registry expiration date",
605
762
  "expiry date",
606
763
  "expiration date",
764
+ "registrar registration expiration date",
765
+ "registrar registration expiry date",
766
+ "registrar expiration date",
767
+ "registrar expiry date",
768
+ "domain expires",
607
769
  "paid-till",
608
770
  "expires on",
609
771
  "renewal date"
@@ -656,6 +818,8 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
656
818
  return ns;
657
819
  }).filter((x) => !!x)) : void 0;
658
820
  const contacts = collectContacts(map);
821
+ const registrant = contacts?.find((c) => c.type === "registrant");
822
+ const privacyEnabled = !!(registrant && [registrant.name, registrant.organization].filter(Boolean).some(isPrivacyName));
659
823
  const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
660
824
  const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : void 0;
661
825
  const transferLock = !!statuses?.some((s) => /transferprohibited/i.test(s.status));
@@ -678,6 +842,7 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
678
842
  dnssec,
679
843
  nameservers,
680
844
  contacts,
845
+ privacyEnabled: privacyEnabled ? true : void 0,
681
846
  whoisServer,
682
847
  rdapServers: void 0,
683
848
  rawRdap: void 0,
@@ -771,6 +936,37 @@ function multi(map, keys) {
771
936
  }
772
937
  }
773
938
 
939
+ //#endregion
940
+ //#region src/whois/referral.ts
941
+ /**
942
+ * Follow registrar WHOIS referrals up to a configured hop limit.
943
+ * Returns the last successful WHOIS response (best-effort; keeps original on failures).
944
+ */
945
+ async function followWhoisReferrals(initialServer, domain, opts) {
946
+ const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
947
+ let current = await whoisQuery(initialServer, domain, opts);
948
+ if (opts?.followWhoisReferral === false || maxHops === 0) return current;
949
+ const visited = new Set([normalize(current.serverQueried)]);
950
+ let hops = 0;
951
+ while (hops < maxHops) {
952
+ const next = extractWhoisReferral(current.text);
953
+ if (!next) break;
954
+ const normalized = normalize(next);
955
+ if (visited.has(normalized)) break;
956
+ visited.add(normalized);
957
+ try {
958
+ current = await whoisQuery(next, domain, opts);
959
+ } catch {
960
+ break;
961
+ }
962
+ hops += 1;
963
+ }
964
+ return current;
965
+ }
966
+ function normalize(server) {
967
+ return server.replace(/^whois:\/\//i, "").toLowerCase();
968
+ }
969
+
774
970
  //#endregion
775
971
  //#region src/index.ts
776
972
  /**
@@ -792,9 +988,10 @@ async function lookupDomain(domain, opts) {
792
988
  tried.push(base);
793
989
  try {
794
990
  const { json } = await fetchRdapDomain(domain, base, opts);
991
+ const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
795
992
  return {
796
993
  ok: true,
797
- record: normalizeRdap(domain, tld, json, tried, now, !!opts?.includeRaw)
994
+ record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], now, !!opts?.includeRaw)
798
995
  };
799
996
  } catch {}
800
997
  }
@@ -813,13 +1010,7 @@ async function lookupDomain(domain, opts) {
813
1010
  error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
814
1011
  };
815
1012
  }
816
- let res = await whoisQuery(whoisServer, domain, opts);
817
- if (opts?.followWhoisReferral !== false) {
818
- const referral = extractWhoisReferral(res.text);
819
- if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) try {
820
- res = await whoisQuery(referral, domain, opts);
821
- } catch {}
822
- }
1013
+ const res = await followWhoisReferrals(whoisServer, domain, opts);
823
1014
  if (publicSuffix.includes(".") && /no match|not found/i.test(res.text) && opts?.followWhoisReferral !== false) {
824
1015
  const candidates = [];
825
1016
  const ps = publicSuffix.toLowerCase();
@@ -827,10 +1018,10 @@ async function lookupDomain(domain, opts) {
827
1018
  if (exception) candidates.push(exception);
828
1019
  for (const server of candidates) try {
829
1020
  const alt = await whoisQuery(server, domain, opts);
830
- if (alt.text && !/error/i.test(alt.text)) {
831
- res = alt;
832
- break;
833
- }
1021
+ if (alt.text && !/error/i.test(alt.text)) return {
1022
+ ok: true,
1023
+ record: normalizeWhois(domain, tld, alt.text, alt.serverQueried, now, !!opts?.includeRaw)
1024
+ };
834
1025
  } catch {}
835
1026
  }
836
1027
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdapper",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "🎩 RDAP/WHOIS fetcher, parser, and normalizer for Node",
6
6
  "repository": {