@cyanheads/whois-mcp-server 0.1.5 → 0.2.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.
Files changed (52) hide show
  1. package/AGENTS.md +27 -14
  2. package/CLAUDE.md +27 -14
  3. package/Dockerfile +48 -34
  4. package/README.md +30 -35
  5. package/changelog/0.1.x/0.1.6.md +41 -0
  6. package/changelog/0.2.x/0.2.0.md +40 -0
  7. package/dist/app.d.ts +67 -26
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +7 -5
  10. package/dist/app.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/_fqdn.d.ts +9 -3
  12. package/dist/mcp-server/tools/definitions/_fqdn.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/_fqdn.js +10 -14
  14. package/dist/mcp-server/tools/definitions/_fqdn.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts +11 -5
  16. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js +25 -40
  18. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts +23 -4
  20. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js +44 -28
  22. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts +0 -5
  24. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js +58 -53
  26. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts +2 -2
  28. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js +9 -5
  30. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts +16 -9
  32. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js +60 -24
  34. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts +16 -2
  36. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js +44 -17
  38. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js.map +1 -1
  39. package/dist/services/doh/doh-service.d.ts +3 -3
  40. package/dist/services/doh/doh-service.d.ts.map +1 -1
  41. package/dist/services/doh/doh-service.js +71 -24
  42. package/dist/services/doh/doh-service.js.map +1 -1
  43. package/dist/services/doh/types.d.ts +16 -0
  44. package/dist/services/doh/types.d.ts.map +1 -1
  45. package/dist/services/rdap/rdap-service.d.ts +20 -8
  46. package/dist/services/rdap/rdap-service.d.ts.map +1 -1
  47. package/dist/services/rdap/rdap-service.js +200 -145
  48. package/dist/services/rdap/rdap-service.js.map +1 -1
  49. package/dist/services/rdap/types.d.ts +32 -2
  50. package/dist/services/rdap/types.d.ts.map +1 -1
  51. package/package.json +14 -12
  52. package/server.json +11 -25
@@ -7,12 +7,18 @@ import type { Context } from '@cyanheads/mcp-ts-core';
7
7
  import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
8
8
  import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
9
9
  import type { NormalizedAsn, NormalizedDomain, NormalizedIpNetwork } from './types.js';
10
- /** Validate an IP address or CIDR notation */
11
- export declare function validateIp(ip: string): {
12
- valid: boolean;
10
+ /** Parsed lookup address; mapped IPv6 resolves through its embedded IPv4 address. */
11
+ type ParsedIp = {
12
+ valid: true;
13
+ base: string;
14
+ value: bigint;
13
15
  isIpv6: boolean;
14
16
  hasCidr: boolean;
15
17
  };
18
+ /** Parse a complete IP/CIDR token, retaining base-address lookup semantics. */
19
+ export declare function validateIp(ip: string): ParsedIp | {
20
+ valid: false;
21
+ };
16
22
  /** Build reverse PTR query name from an IPv4 address */
17
23
  export declare function ipv4ToPtr(ip: string): string;
18
24
  /** Build reverse PTR query name from an IPv6 address */
@@ -28,19 +34,24 @@ export declare class RdapService {
28
34
  /** Find the RDAP server URL for an ASN via IANA bootstrap */
29
35
  findAsnRdapServer(asnNum: number, ctx: Context): Promise<string | null>;
30
36
  /** Look up a domain registration record via RDAP */
31
- lookupDomain(domain: string, ctx: Context): Promise<NormalizedDomain>;
37
+ lookupDomain(domain: string, ctx: Context, includeRegistrar?: boolean): Promise<NormalizedDomain>;
38
+ /** One optional registrar exchange, with guarded redirects and a total deadline. */
39
+ private registrarDetail;
32
40
  /**
33
- * Check domain availability via RDAP.
34
- * Returns `{ available: true }` on 404, normalized record on 200,
35
- * or `{ available: null }` when TLD has no RDAP coverage.
41
+ * Check registry record presence; a miss never establishes registration eligibility.
42
+ * Recognized ICANN subdomains are rejected without querying their parent.
36
43
  */
37
44
  checkAvailability(domain: string, ctx: Context): Promise<{
38
- available: true;
45
+ available: null;
46
+ registration_status: 'not_found';
47
+ rdap_coverage: true;
39
48
  } | {
40
49
  available: false;
50
+ registration_status: 'registered';
41
51
  record: NormalizedDomain;
42
52
  } | {
43
53
  available: null;
54
+ registration_status: 'unknown';
44
55
  rdap_coverage: false;
45
56
  }>;
46
57
  /** Look up an IP address or CIDR via RIR RDAP */
@@ -50,4 +61,5 @@ export declare class RdapService {
50
61
  }
51
62
  export declare function initRdapService(_config: AppConfig, _storage: StorageService): void;
52
63
  export declare function getRdapService(): RdapService;
64
+ export {};
53
65
  //# sourceMappingURL=rdap-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rdap-service.d.ts","sourceRoot":"","sources":["../../../src/services/rdap/rdap-service.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAO/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAGrE,OAAO,KAAK,EAEV,aAAa,EACb,gBAAgB,EAChB,mBAAmB,EAMpB,MAAM,YAAY,CAAC;AA+DpB,8CAA8C;AAC9C,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAoB5F;AAED,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5C;AAiBD,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAiB5C;AAuLD,qBAAa,WAAW;IACtB,OAAO,CAAC,cAAc,CAAsE;IAE5F,iDAAiD;YACnC,cAAc;IAgB5B,qEAAqE;IAC/D,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe/E;IAED,oEAAoE;IAC9D,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiDxF;IAED,6DAA6D;IACvD,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe5E;IAED,oDAAoD;IAC9C,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgD1E;IAED;;;;OAIG;IACG,iBAAiB,CACrB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,OAAO,GACX,OAAO,CACN;QAAE,SAAS,EAAE,IAAI,CAAA;KAAE,GACnB;QAAE,SAAS,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAA;KAAE,GAC9C;QAAE,SAAS,EAAE,IAAI,CAAC;QAAC,aAAa,EAAE,KAAK,CAAA;KAAE,CAC5C,CAwCA;IAED,iDAAiD;IAC3C,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA8DrE;IAED,kCAAkC;IAC5B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAwDjE;CACF;AAMD,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAElF;AAED,wBAAgB,cAAc,IAAI,WAAW,CAK5C"}
1
+ {"version":3,"file":"rdap-service.d.ts","sourceRoot":"","sources":["../../../src/services/rdap/rdap-service.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAO/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAIrE,OAAO,KAAK,EAEV,aAAa,EACb,gBAAgB,EAChB,mBAAmB,EAMpB,MAAM,YAAY,CAAC;AAUpB,qFAAqF;AACrF,KAAK,QAAQ,GAAG;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAEhG,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,QAAQ,GAAG;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAuBlE;AAuBD,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAE5C;AAsBD,wDAAwD;AACxD,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAG5C;AA0ND,qBAAa,WAAW;IACtB,OAAO,CAAC,cAAc,CAAsE;IAE5F,iDAAiD;YACnC,cAAc;IAgB5B,qEAAqE;IAC/D,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe/E;IAED,oEAAoE;IAC9D,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiDxF;IAED,6DAA6D;IACvD,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAe5E;IAED,oDAAoD;IAC9C,YAAY,CAChB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,OAAO,EACZ,gBAAgB,UAAQ,GACvB,OAAO,CAAC,gBAAgB,CAAC,CAoD3B;IAED,oFAAoF;YACtE,eAAe;IAqE7B;;;OAGG;IACG,iBAAiB,CACrB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,OAAO,GACX,OAAO,CACN;QAAE,SAAS,EAAE,IAAI,CAAC;QAAC,mBAAmB,EAAE,WAAW,CAAC;QAAC,aAAa,EAAE,IAAI,CAAA;KAAE,GAC1E;QAAE,SAAS,EAAE,KAAK,CAAC;QAAC,mBAAmB,EAAE,YAAY,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAA;KAAE,GACjF;QAAE,SAAS,EAAE,IAAI,CAAC;QAAC,mBAAmB,EAAE,SAAS,CAAC;QAAC,aAAa,EAAE,KAAK,CAAA;KAAE,CAC5E,CAkDA;IAED,iDAAiD;IAC3C,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAiErE;IAED,kCAAkC;IAC5B,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAwDjE;CACF;AAMD,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,GAAG,IAAI,CAElF;AAED,wBAAgB,cAAc,IAAI,WAAW,CAK5C"}
@@ -3,8 +3,10 @@
3
3
  * Caches the IANA bootstrap JSON for 24 hours to avoid a bootstrap hop on every query.
4
4
  * @module services/rdap/rdap-service
5
5
  */
6
+ import { isIP } from 'node:net';
6
7
  import { McpError, notFound, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
8
  import { fetchWithTimeout, withRetry } from '@cyanheads/mcp-ts-core/utils';
9
+ import { parse } from 'tldts';
8
10
  import { getServerConfig } from '../../config/server-config.js';
9
11
  // IANA bootstrap endpoints
10
12
  const IANA_DNS_BOOTSTRAP = 'https://data.iana.org/rdap/dns.json';
@@ -12,75 +14,46 @@ const IANA_IPV4_BOOTSTRAP = 'https://data.iana.org/rdap/ipv4.json';
12
14
  const IANA_IPV6_BOOTSTRAP = 'https://data.iana.org/rdap/ipv6.json';
13
15
  const IANA_ASN_BOOTSTRAP = 'https://data.iana.org/rdap/asn.json';
14
16
  const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000; // 24h
15
- // Private/reserved IPv4 ranges
16
- const PRIVATE_IPV4_RANGES = [
17
- /^10\./,
18
- /^172\.(1[6-9]|2\d|3[01])\./,
19
- /^192\.168\./,
20
- /^127\./,
21
- /^169\.254\./,
22
- /^100\.(6[4-9]|[7-9]\d|1([01]\d|2[0-7]))\./, // CGNAT 100.64-100.127
23
- /^0\./,
24
- /^255\./,
25
- ];
26
- // Private/reserved IPv6 ranges (string prefix checks)
27
- const PRIVATE_IPV6_PREFIXES = [
28
- '::1', // loopback
29
- 'fc', // ULA fc00::/7
30
- 'fd', // ULA fd00::/7
31
- 'fe80', // link-local fe80::/10
32
- ];
33
- function isPrivateIpv4(ip) {
34
- return PRIVATE_IPV4_RANGES.some((r) => r.test(ip));
35
- }
36
- function isPrivateIpv6(ip) {
37
- const lower = ip.toLowerCase();
38
- return PRIVATE_IPV6_PREFIXES.some((p) => lower.startsWith(p));
39
- }
40
- /** Check if an IP or CIDR base is private/reserved. */
41
- function isPrivateIp(ip) {
42
- const base = ip.split('/')[0] ?? '';
43
- if (base.includes(':'))
44
- return isPrivateIpv6(base);
45
- return isPrivateIpv4(base);
46
- }
47
- /** Simple IPv4 validation */
48
- function isValidIpv4(ip) {
49
- const parts = ip.split('.');
50
- if (parts.length !== 4)
51
- return false;
52
- return parts.every((p) => {
53
- const n = parseInt(p, 10);
54
- return !Number.isNaN(n) && n >= 0 && n <= 255 && String(n) === p;
55
- });
56
- }
57
- /** Simple IPv6 validation — accepts compressed forms */
58
- function isValidIpv6(ip) {
59
- if (!ip.includes(':'))
60
- return false;
61
- if (ip.split(':').length > 8)
62
- return false;
63
- return /^[0-9a-fA-F:]+$/.test(ip);
64
- }
65
- /** Validate an IP address or CIDR notation */
17
+ /** Parse a complete IP/CIDR token, retaining base-address lookup semantics. */
66
18
  export function validateIp(ip) {
67
- const parts = ip.split('/');
68
- const base = parts[0] ?? '';
69
- const hasCidr = parts.length === 2;
70
- if (hasCidr) {
71
- const prefix = parseInt(parts[1] ?? '', 10);
72
- if (Number.isNaN(prefix) || prefix < 0)
73
- return { valid: false, isIpv6: false, hasCidr: true };
19
+ const [base = '', prefix, ...extra] = ip.split('/');
20
+ const family = isIP(base);
21
+ if (!family || base.includes('%') || extra.length > 0)
22
+ return { valid: false };
23
+ if (prefix !== undefined &&
24
+ (!/^\d+$/.test(prefix) || Number(prefix) > (family === 6 ? 128 : 32))) {
25
+ return { valid: false };
74
26
  }
75
- const isIpv6 = base.includes(':');
76
- if (isIpv6) {
77
- if (hasCidr && parseInt(parts[1] ?? '129', 10) > 128)
78
- return { valid: false, isIpv6: true, hasCidr: true };
79
- return { valid: isValidIpv6(base), isIpv6: true, hasCidr };
27
+ const value = family === 6 ? ipv6ToBigInt(base) : ipv4ToBigInt(base);
28
+ const mapped = family === 6 && value >> 32n === 0xffffn;
29
+ const lookupValue = mapped ? value & 0xffffffffn : value;
30
+ const lookupBase = mapped
31
+ ? [24n, 16n, 8n, 0n].map((shift) => Number((lookupValue >> shift) & 255n)).join('.')
32
+ : base;
33
+ return {
34
+ valid: true,
35
+ base: lookupBase,
36
+ value: lookupValue,
37
+ isIpv6: family === 6 && !mapped,
38
+ hasCidr: prefix !== undefined,
39
+ };
40
+ }
41
+ /** Apply the server's explicit exclusions, not a blanket special-use policy. */
42
+ function isPrivateIp(ip) {
43
+ if (ip.isIpv6) {
44
+ return ip.value === 1n || ip.value >> 121n === 0x7en || ip.value >> 118n === 0x3fan;
80
45
  }
81
- if (hasCidr && parseInt(parts[1] ?? '33', 10) > 32)
82
- return { valid: false, isIpv6: false, hasCidr: true };
83
- return { valid: isValidIpv4(base), isIpv6: false, hasCidr };
46
+ const first = Number(ip.value >> 24n);
47
+ const second = Number((ip.value >> 16n) & 255n);
48
+ return ([0, 10, 127, 255].includes(first) ||
49
+ (first === 100 && second >= 64 && second <= 127) ||
50
+ (first === 169 && second === 254) ||
51
+ (first === 172 && second >= 16 && second <= 31) ||
52
+ (first === 192 && second === 168));
53
+ }
54
+ /** Convert a validated IPv4 address to its numeric value. */
55
+ function ipv4ToBigInt(ip) {
56
+ return ip.split('.').reduce((value, octet) => (value << 8n) | BigInt(octet), 0n);
84
57
  }
85
58
  /** Build reverse PTR query name from an IPv4 address */
86
59
  export function ipv4ToPtr(ip) {
@@ -88,6 +61,11 @@ export function ipv4ToPtr(ip) {
88
61
  }
89
62
  /** Expand a compressed IPv6 address to a full 128-bit BigInt */
90
63
  function ipv6ToBigInt(ip) {
64
+ if (ip.includes('.')) {
65
+ const lastColon = ip.lastIndexOf(':');
66
+ const tail = ipv4ToBigInt(ip.slice(lastColon + 1));
67
+ ip = `${ip.slice(0, lastColon + 1)}${(tail >> 16n).toString(16)}:${(tail & 0xffffn).toString(16)}`;
68
+ }
91
69
  let groups;
92
70
  if (ip.includes('::')) {
93
71
  const parts = ip.split('::');
@@ -103,22 +81,7 @@ function ipv6ToBigInt(ip) {
103
81
  }
104
82
  /** Build reverse PTR query name from an IPv6 address */
105
83
  export function ipv6ToPtr(ip) {
106
- let hex;
107
- // Handle :: compression
108
- if (ip.includes('::')) {
109
- const parts = ip.split('::');
110
- const left = parts[0] ? parts[0].split(':') : [];
111
- const right = parts[1] ? parts[1].split(':') : [];
112
- const missing = 8 - left.length - right.length;
113
- const full = [...left, ...Array(missing).fill('0'), ...right];
114
- hex = full.map((g) => g.padStart(4, '0')).join('');
115
- }
116
- else {
117
- hex = ip
118
- .split(':')
119
- .map((g) => g.padStart(4, '0'))
120
- .join('');
121
- }
84
+ const hex = ipv6ToBigInt(ip).toString(16).padStart(32, '0');
122
85
  return `${hex.split('').reverse().join('.')}.ip6.arpa`;
123
86
  }
124
87
  /** Extract top-level TLD (last label) */
@@ -134,17 +97,15 @@ function extractTwoLabelSuffix(domain) {
134
97
  return labels.slice(-2).join('.');
135
98
  }
136
99
  /** Identify RIR from an RDAP self-link URL */
137
- function ririFromSelfLink(selfLink) {
138
- if (selfLink.includes('arin.net'))
139
- return 'ARIN';
140
- if (selfLink.includes('ripe.net'))
141
- return 'RIPE';
142
- if (selfLink.includes('apnic.net'))
143
- return 'APNIC';
144
- if (selfLink.includes('lacnic.net'))
145
- return 'LACNIC';
146
- if (selfLink.includes('afrinic.net'))
147
- return 'AFRINIC';
100
+ function rirFromSelfLink(selfLink) {
101
+ const url = URL.parse(selfLink);
102
+ if (!url)
103
+ return;
104
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
105
+ for (const rir of ['arin', 'ripe', 'apnic', 'lacnic', 'afrinic']) {
106
+ if (hostname === `${rir}.net` || hostname.endsWith(`.${rir}.net`))
107
+ return rir.toUpperCase();
108
+ }
148
109
  return;
149
110
  }
150
111
  /** Extract vCard field value by field name */
@@ -191,6 +152,29 @@ function findEntityByRole(entities, role) {
191
152
  function registrarIanaId(entity) {
192
153
  return entity.publicIds?.find((p) => p.type === 'IANA Registrar ID')?.identifier;
193
154
  }
155
+ const REDACTED_VALUES = new Set([
156
+ 'REDACTED FOR PRIVACY',
157
+ 'REDACTED REGISTRANT',
158
+ 'REDACTED',
159
+ 'DATA REDACTED',
160
+ 'NOT DISCLOSED',
161
+ ]);
162
+ /** Registrant organization and explicit, scoped privacy evidence; absence stays unknown. */
163
+ function registrantEvidence(raw) {
164
+ const registrant = findEntityByRole(raw.entities ?? [], 'registrant');
165
+ const org = registrant?.vcardArray ? vcardField(registrant.vcardArray[1], 'org') : undefined;
166
+ const placeholder = (value) => value !== undefined && REDACTED_VALUES.has(value.trim().toUpperCase());
167
+ const entityRedaction = registrant &&
168
+ (placeholder(entityName(registrant)) ||
169
+ placeholder(org) ||
170
+ (registrant.redacted?.length ?? 0) > 0 ||
171
+ registrant.remarks?.some((remark) => /redact|privacy/i.test([remark.title, ...(remark.description ?? [])].join(' '))));
172
+ const scopedRedaction = raw.redacted?.some((entry) => /registrant/i.test([entry.name?.type, entry.name?.description, entry.prePath, entry.postPath].join(' ')));
173
+ return {
174
+ ...(org && !placeholder(org) ? { registrant_org: org } : {}),
175
+ registrant_redacted: entityRedaction || scopedRedaction ? true : null,
176
+ };
177
+ }
194
178
  /** Normalize RDAP domain response to our output shape */
195
179
  function normalizeDomain(raw) {
196
180
  const events = raw.events ?? [];
@@ -202,9 +186,7 @@ function normalizeDomain(raw) {
202
186
  const registrarEntity = findEntityByRole(entities, 'registrar');
203
187
  const registrar = registrarEntity ? entityName(registrarEntity) : undefined;
204
188
  const registrarIanaIdVal = registrarEntity ? registrarIanaId(registrarEntity) : undefined;
205
- const registrantEntity = findEntityByRole(entities, 'registrant');
206
- const registrantOrg = registrantEntity ? entityName(registrantEntity) : undefined;
207
- const registrantRedacted = !registrantOrg;
189
+ const evidence = registrantEvidence(raw);
208
190
  const rdapLastUpdated = getDate('last update of RDAP database');
209
191
  const createdDate = getDate('registration');
210
192
  const updatedDate = getDate('last changed');
@@ -216,8 +198,8 @@ function normalizeDomain(raw) {
216
198
  rdap_coverage: true,
217
199
  status: raw.status ?? [],
218
200
  nameservers,
219
- dnssec_signed: raw.secureDNS?.delegationSigned ?? false,
220
- registrant_redacted: registrantRedacted,
201
+ dnssec_signed: raw.secureDNS?.delegationSigned ?? null,
202
+ ...evidence,
221
203
  };
222
204
  if (raw.handle !== undefined)
223
205
  result.handle = raw.handle;
@@ -233,8 +215,6 @@ function normalizeDomain(raw) {
233
215
  result.expiry_date = expiryDate;
234
216
  if (rdapLastUpdated !== undefined)
235
217
  result.rdap_last_updated = rdapLastUpdated;
236
- if (registrantOrg !== undefined)
237
- result.registrant_org = registrantOrg;
238
218
  return result;
239
219
  }
240
220
  /** Normalize RDAP IP network response */
@@ -244,27 +224,27 @@ function normalizeIpNetwork(raw, ip) {
244
224
  const abuse = findEntityByRole(entities, 'abuse');
245
225
  const orgName = registrant ? entityName(registrant) : undefined;
246
226
  const abuseEmail = abuse ? entityEmail(abuse) : undefined;
247
- let cidr;
248
- if (raw.cidr0_cidrs && raw.cidr0_cidrs.length > 0) {
249
- const c = raw.cidr0_cidrs[0];
250
- if (c) {
251
- if (c.v4prefix !== undefined && c.length !== undefined)
252
- cidr = `${c.v4prefix}/${c.length}`;
253
- else if (c.v6prefix !== undefined && c.length !== undefined)
254
- cidr = `${c.v6prefix}/${c.length}`;
255
- }
256
- }
227
+ const cidrs = (raw.cidr0_cidrs ?? []).flatMap((component) => {
228
+ const prefix = component.v4prefix ?? component.v6prefix;
229
+ return prefix !== undefined && component.length !== undefined
230
+ ? [`${prefix}/${component.length}`]
231
+ : [];
232
+ });
257
233
  const selfLink = raw.links?.find((l) => l.rel === 'self')?.href ?? '';
258
- const rdapSource = ririFromSelfLink(selfLink);
259
- const result = { ip, ptr: null };
234
+ const rdapSource = rirFromSelfLink(selfLink);
235
+ const result = {
236
+ ip,
237
+ cidrs,
238
+ ...(cidrs.length === 1 ? { cidr: cidrs.join() } : {}),
239
+ ptr: null,
240
+ ptr_status: 'not_found',
241
+ };
260
242
  if (raw.handle !== undefined)
261
243
  result.handle = raw.handle;
262
244
  if (raw.startAddress !== undefined)
263
245
  result.start_address = raw.startAddress;
264
246
  if (raw.endAddress !== undefined)
265
247
  result.end_address = raw.endAddress;
266
- if (cidr !== undefined)
267
- result.cidr = cidr;
268
248
  if (raw.ipVersion !== undefined)
269
249
  result.ip_version = raw.ipVersion;
270
250
  if (raw.name !== undefined)
@@ -287,7 +267,7 @@ function normalizeAutnum(raw, asn) {
287
267
  const registrant = findEntityByRole(entities, 'registrant');
288
268
  const orgName = registrant ? entityName(registrant) : undefined;
289
269
  const selfLink = raw.links?.find((l) => l.rel === 'self')?.href ?? '';
290
- const rir = ririFromSelfLink(selfLink);
270
+ const rir = rirFromSelfLink(selfLink);
291
271
  const result = { asn };
292
272
  if (raw.handle !== undefined)
293
273
  result.handle = raw.handle;
@@ -383,7 +363,7 @@ export class RdapService {
383
363
  const netParts = net.split('.').map(Number);
384
364
  if (netParts.length !== 4 || parts.length !== 4)
385
365
  continue;
386
- const mask = ~((1 << (32 - prefixLen)) - 1);
366
+ const mask = prefixLen === 0 ? 0 : ~((1 << (32 - prefixLen)) - 1);
387
367
  const [n0, n1, n2, n3] = netParts;
388
368
  const [p0, p1, p2, p3] = parts;
389
369
  const netNum = (n0 << 24) | (n1 << 16) | (n2 << 8) | n3;
@@ -413,7 +393,8 @@ export class RdapService {
413
393
  return null;
414
394
  }
415
395
  /** Look up a domain registration record via RDAP */
416
- async lookupDomain(domain, ctx) {
396
+ async lookupDomain(domain, ctx, includeRegistrar = false) {
397
+ ctx.signal.throwIfAborted();
417
398
  const rdapServer = await this.findDomainRdapServer(domain, ctx);
418
399
  if (!rdapServer) {
419
400
  return {
@@ -421,8 +402,8 @@ export class RdapService {
421
402
  rdap_coverage: false,
422
403
  status: [],
423
404
  nameservers: [],
424
- dnssec_signed: false,
425
- registrant_redacted: false,
405
+ dnssec_signed: null,
406
+ registrant_redacted: null,
426
407
  };
427
408
  }
428
409
  const config = getServerConfig();
@@ -441,15 +422,19 @@ export class RdapService {
441
422
  maxRetries: config.rdapMaxRetries,
442
423
  baseDelayMs: 1000,
443
424
  signal: ctx.signal,
444
- // 404 is NOT transient — domain is not registered; don't retry
425
+ // A missing registry object is terminal, not an eligibility statement.
445
426
  isTransient: (err) => !isRdapNotFound(err),
446
427
  });
447
- return normalizeDomain(raw);
428
+ const record = normalizeDomain(raw);
429
+ ctx.signal.throwIfAborted();
430
+ if (includeRegistrar)
431
+ record.registrar_detail = await this.registrarDetail(raw, domain, ctx);
432
+ return record;
448
433
  }
449
434
  catch (err) {
450
- // RDAP 404 on a domain lookup means the domain is not registered
435
+ // RDAP 404 establishes record absence only.
451
436
  if (isRdapNotFound(err)) {
452
- throw notFound(`Domain "${domain}" is not registered (RDAP 404).`, {
437
+ throw notFound(`No RDAP domain record found for "${domain}" (404).`, {
453
438
  reason: 'domain_not_found',
454
439
  domain: domain.toLowerCase(),
455
440
  });
@@ -457,15 +442,82 @@ export class RdapService {
457
442
  throw err;
458
443
  }
459
444
  }
445
+ /** One optional registrar exchange, with guarded redirects and a total deadline. */
446
+ async registrarDetail(raw, domain, ctx) {
447
+ const referral = raw.links?.find((link) => link.rel === 'related' && link.type?.toLowerCase() === 'application/rdap+json' && link.href)?.href;
448
+ if (!referral)
449
+ return { status: 'no_referral' };
450
+ try {
451
+ const url = new URL(referral);
452
+ if (url.protocol !== 'https:' || url.username || url.password)
453
+ throw validationError('Registrar referral must use HTTPS without credentials.');
454
+ const signal = AbortSignal.any([
455
+ ctx.signal,
456
+ AbortSignal.timeout(getServerConfig().rdapTimeoutMs),
457
+ ]);
458
+ signal.throwIfAborted();
459
+ const aborted = Promise.withResolvers();
460
+ const onAbort = () => aborted.reject(signal.reason);
461
+ signal.addEventListener('abort', onAbort, { once: true });
462
+ let response;
463
+ try {
464
+ // The guard's DNS preflight precedes its fetch timer; bound that await as well.
465
+ response = await Promise.race([
466
+ fetchWithTimeout(url, getServerConfig().rdapTimeoutMs, ctx, {
467
+ headers: { Accept: 'application/rdap+json' },
468
+ signal,
469
+ rejectPrivateIPs: true,
470
+ }),
471
+ aborted.promise,
472
+ ]);
473
+ }
474
+ finally {
475
+ signal.removeEventListener('abort', onAbort);
476
+ }
477
+ if (!response.headers
478
+ .get('content-type')
479
+ ?.toLowerCase()
480
+ .split(';')[0]
481
+ ?.trim()
482
+ .match(/^application\/(?:rdap\+json|json)$/))
483
+ throw serviceUnavailable('Registrar response is not RDAP JSON.');
484
+ const detail = await response.json();
485
+ signal.throwIfAborted();
486
+ if (!detail ||
487
+ typeof detail !== 'object' ||
488
+ !('objectClassName' in detail) ||
489
+ detail.objectClassName !== 'domain' ||
490
+ !('ldhName' in detail) ||
491
+ typeof detail.ldhName !== 'string' ||
492
+ detail.ldhName.toLowerCase() !== domain.toLowerCase())
493
+ throw serviceUnavailable('Registrar response does not contain the matching domain object.');
494
+ const evidence = registrantEvidence(detail);
495
+ return { status: 'found', source: response.url || url.href, ...evidence };
496
+ }
497
+ catch (error) {
498
+ ctx.signal.throwIfAborted();
499
+ return {
500
+ status: 'failed',
501
+ source: referral,
502
+ source_error: error instanceof Error ? error.message : String(error),
503
+ };
504
+ }
505
+ }
460
506
  /**
461
- * Check domain availability via RDAP.
462
- * Returns `{ available: true }` on 404, normalized record on 200,
463
- * or `{ available: null }` when TLD has no RDAP coverage.
507
+ * Check registry record presence; a miss never establishes registration eligibility.
508
+ * Recognized ICANN subdomains are rejected without querying their parent.
464
509
  */
465
510
  async checkAvailability(domain, ctx) {
511
+ const suffix = parse(domain, { allowPrivateDomains: false });
512
+ if (suffix.isIcann && suffix.domain && suffix.subdomain) {
513
+ throw validationError(`"${domain}" is a subdomain; its registrable parent is "${suffix.domain}".`, {
514
+ reason: 'non_registrable_domain',
515
+ registrable_parent: suffix.domain,
516
+ });
517
+ }
466
518
  const rdapServer = await this.findDomainRdapServer(domain, ctx);
467
519
  if (!rdapServer) {
468
- return { available: null, rdap_coverage: false };
520
+ return { available: null, registration_status: 'unknown', rdap_coverage: false };
469
521
  }
470
522
  const config = getServerConfig();
471
523
  const url = `${rdapServer}/domain/${encodeURIComponent(domain.toLowerCase())}`;
@@ -483,39 +535,42 @@ export class RdapService {
483
535
  maxRetries: config.rdapMaxRetries,
484
536
  baseDelayMs: 1000,
485
537
  signal: ctx.signal,
486
- // 404 is NOT transient — it's the availability signal
538
+ // A registry miss is terminal record-status data.
487
539
  isTransient: (err) => !isRdapNotFound(err),
488
540
  });
489
541
  const record = normalizeDomain(raw);
490
- return { available: false, record };
542
+ return { available: false, registration_status: 'registered', record };
491
543
  }
492
544
  catch (err) {
493
- // A 404 from the RDAP server means the domain is available (not registered)
545
+ // Preserve record absence independently of registration eligibility.
494
546
  if (isRdapNotFound(err)) {
495
- return { available: true };
547
+ return { available: null, registration_status: 'not_found', rdap_coverage: true };
496
548
  }
497
549
  throw err;
498
550
  }
499
551
  }
500
552
  /** Look up an IP address or CIDR via RIR RDAP */
501
553
  async lookupIp(ip, ctx) {
502
- const { valid, isIpv6 } = validateIp(ip);
503
- if (!valid) {
554
+ const parsed = validateIp(ip);
555
+ if (!parsed.valid) {
504
556
  throw validationError(`"${ip}" is not a valid IPv4, IPv6, or CIDR address.`, {
505
557
  reason: 'invalid_ip',
506
- ...ctx.recoveryFor('invalid_ip'),
507
558
  });
508
559
  }
509
- const base = ip.split('/')[0] ?? '';
510
- if (isPrivateIp(base)) {
511
- throw validationError(`"${base}" is a private or reserved IP address — no RIR RDAP record exists for it.`, {
560
+ const { base, isIpv6 } = parsed;
561
+ if (isPrivateIp(parsed)) {
562
+ throw validationError(`"${base}" is in an address range excluded by this server's lookup policy.`, {
512
563
  reason: 'private_range',
513
- ...ctx.recoveryFor('private_range'),
514
564
  });
515
565
  }
516
- const rdapServer = await this.findIpRdapServer(ip, isIpv6, ctx);
566
+ const rdapServer = await this.findIpRdapServer(base, isIpv6, ctx);
517
567
  if (!rdapServer) {
518
- throw serviceUnavailable('No RIR RDAP server found for this IP address range.', { ip });
568
+ throw notFound('No RIR RDAP route covers this IP address in the IANA bootstrap.', {
569
+ ip,
570
+ reason: 'ip_no_rdap_route',
571
+ retryable: false,
572
+ ...ctx.recoveryFor('ip_no_rdap_route'),
573
+ });
519
574
  }
520
575
  const config = getServerConfig();
521
576
  // IPv6 addresses contain colons which must NOT be percent-encoded in the RDAP path.
@@ -553,12 +608,12 @@ export class RdapService {
553
608
  }
554
609
  /** Look up an ASN via RIR RDAP */
555
610
  async lookupAsn(asn, ctx) {
556
- const asnClean = asn.toUpperCase().replace(/^AS/, '');
557
- const asnNum = parseInt(asnClean, 10);
558
- if (Number.isNaN(asnNum) || asnNum <= 0) {
559
- throw validationError(`"${asn}" is not a valid ASN. Expected AS<number> or bare integer (e.g., AS15169 or 15169).`, {
611
+ const token = asn.trim();
612
+ const match = /^(?:AS\s*)?\+?(\d+)$/i.exec(token);
613
+ const asnNum = match ? Number(match[1]) : NaN;
614
+ if (!Number.isInteger(asnNum) || asnNum < 1 || asnNum > 4294967295) {
615
+ throw validationError(`"${asn}" is not a valid ASN. Expected a decimal integer from 1 to 4294967295, optionally prefixed with AS and a leading + (e.g., AS15169 or +15169).`, {
560
616
  reason: 'invalid_asn',
561
- ...ctx.recoveryFor('invalid_asn'),
562
617
  });
563
618
  }
564
619
  const rdapServer = await this.findAsnRdapServer(asnNum, ctx);