feat: add TLD validation with real-time warnings

Implement comprehensive TLD validation that checks domain inputs against
the IANA TLD list and RDAP registry to provide context-aware error
messages and real-time UI warnings.

Key changes:
- Add tld-validation service that validates TLDs against IANA list and
  RDAP registry availability
- Show warning icons in LookupInput for invalid TLDs (error) and valid
  TLDs without RDAP support (warning) with explanatory tooltips
- Enhance error messages in getRegistryURL to differentiate between
  invalid TLDs, valid TLDs without RDAP, and malformed domains
- Add NotFoundError class for better 404 handling with context-specific
  messages per target type (domain, IP, ASN, entity, TLD)
- Make getRegistryURL async to support TLD validation in error path
- Add comprehensive test coverage for TLD validation and error messages
- Track TLD warning displays in telemetry

This improves UX by catching common mistakes (typos, non-existent TLDs,
reserved TLDs) before query execution and providing clear guidance.
This commit is contained in:
Ryan Walters
2025-11-06 15:40:28 -06:00
parent 71ddaadaa0
commit 13fc05226d
8 changed files with 536 additions and 25 deletions

View File

@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation";
import type { Register } from "@/rdap/schemas";
// Mock the registry module
vi.mock("@/rdap/services/registry", () => ({
getRegistry: vi.fn(),
getCachedRegistry: vi.fn(),
}));
describe("extractTld", () => {
it("should extract TLD from standard domain", () => {
expect(extractTld("example.com")).toBe("com");
});
it("should extract TLD from subdomain", () => {
expect(extractTld("www.example.com")).toBe("com");
});
it("should extract TLD from multiple subdomain levels", () => {
expect(extractTld("api.staging.example.com")).toBe("com");
});
it("should return null for single label domain", () => {
expect(extractTld("localhost")).toBeNull();
});
it("should return null for empty string", () => {
expect(extractTld("")).toBeNull();
});
it("should handle uppercase TLDs", () => {
expect(extractTld("example.COM")).toBe("com");
});
it("should handle mixed case domains", () => {
expect(extractTld("Example.CoM")).toBe("com");
});
});
describe("validateDomainTld", () => {
beforeEach(() => {
// Reset mocks before each test
vi.resetModules();
});
describe("with mocked IANA TLD list", () => {
beforeEach(async () => {
// Mock fetch for IANA TLD list
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () =>
Promise.resolve(
`# IANA TLD list
com
net
org
test
localhost
example
invalid`
),
});
// Mock the registry module to return test data
const { getRegistry } = await import("@/rdap/services/registry");
vi.mocked(getRegistry).mockResolvedValue({
description: "Test DNS registry",
publication: "2024-01-01",
version: "1.0",
services: [
[["com", "net", "org"], ["https://rdap.example.com/"]],
// test, localhost, example, invalid are NOT in RDAP registry
],
} as Register);
});
it("should return valid for TLD with RDAP support", async () => {
const result = await validateDomainTld("example.com");
expect(result).toEqual({ type: "valid" });
});
it("should return no-rdap for valid TLD without RDAP support", async () => {
const result = await validateDomainTld("example.test");
expect(result).toEqual({ type: "no-rdap", tld: "test" });
});
it("should return invalid for non-existent TLD", async () => {
const result = await validateDomainTld("example.notreal");
expect(result).toEqual({ type: "invalid", tld: "notreal" });
});
it("should handle subdomains correctly", async () => {
const result = await validateDomainTld("www.example.com");
expect(result).toEqual({ type: "valid" });
});
it("should return invalid for single-label domain", async () => {
const result = await validateDomainTld("localhost");
expect(result).toEqual({ type: "invalid", tld: "localhost" });
});
it("should handle case-insensitive TLDs", async () => {
const result = await validateDomainTld("example.COM");
expect(result).toEqual({ type: "valid" });
});
});
describe("with fetch failures", () => {
beforeEach(async () => {
// Mock fetch to fail for IANA TLD list
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
// Mock the registry module
const { getRegistry } = await import("@/rdap/services/registry");
vi.mocked(getRegistry).mockResolvedValue({
description: "Test DNS registry",
publication: "2024-01-01",
version: "1.0",
services: [[["com"], ["https://rdap.example.com/"]]],
} as Register);
});
it("should gracefully handle IANA list fetch failure", async () => {
const result = await validateDomainTld("example.com");
// Should assume valid when IANA list can't be loaded
expect(result).toEqual({ type: "valid" });
});
});
});
describe("getRegistryURL error messages", () => {
beforeEach(() => {
vi.resetModules();
});
describe("domain TLD errors", () => {
beforeEach(async () => {
// Mock fetch for IANA TLD list
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () =>
Promise.resolve(
`# IANA TLD list
com
net
test`
),
});
// Mock the registry module
const { getCachedRegistry, getRegistry } = await import("@/rdap/services/registry");
vi.mocked(getCachedRegistry).mockReturnValue({
description: "Test DNS registry",
publication: "2024-01-01",
version: "1.0",
services: [
[["com", "net"], ["https://rdap.example.com/"]],
// test is NOT in RDAP registry
],
} as Register);
vi.mocked(getRegistry).mockResolvedValue({
description: "Test DNS registry",
publication: "2024-01-01",
version: "1.0",
services: [
[["com", "net"], ["https://rdap.example.com/"]],
// test is NOT in RDAP registry
],
} as Register);
});
it("should throw enhanced error for invalid TLD", async () => {
const { getRegistryURL } = await import("@/rdap/services/resolver");
await expect(getRegistryURL("domain", "example.invalidtld")).rejects.toThrow(
'The TLD ".invalidtld" is not recognized as a valid top-level domain'
);
});
it("should throw enhanced error for valid TLD without RDAP", async () => {
const { getRegistryURL } = await import("@/rdap/services/resolver");
await expect(getRegistryURL("domain", "example.test")).rejects.toThrow(
'The TLD ".test" exists but is not available in the IANA RDAP registry'
);
});
it("should throw error for malformed domain", async () => {
const { getRegistryURL } = await import("@/rdap/services/resolver");
await expect(getRegistryURL("domain", "noextension")).rejects.toThrow(
'Invalid domain format: "noextension"'
);
});
it("should not throw error for valid domain with RDAP", async () => {
const { getRegistryURL } = await import("@/rdap/services/resolver");
await expect(getRegistryURL("domain", "example.com")).resolves.toContain(
"https://rdap.example.com/domain/example.com"
);
});
});
});

View File

@@ -30,10 +30,16 @@ const Index: NextPage = () => {
[router] [router]
); );
const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup( const {
undefined, error,
handleUrlUpdate target,
); setTarget,
setTargetType,
submit,
currentType,
manualType,
tldValidation,
} = useLookup(undefined, handleUrlUpdate);
// Parse URL parameters on mount and auto-execute query if present // Parse URL parameters on mount and auto-execute query if present
useEffect(() => { useEffect(() => {
@@ -143,6 +149,7 @@ const Index: NextPage = () => {
<LookupInput <LookupInput
isLoading={isLoading} isLoading={isLoading}
detectedType={currentType} detectedType={currentType}
tldValidation={tldValidation}
shareableUrl={ shareableUrl={
response.isJust && target && typeof window !== "undefined" response.isJust && target && typeof window !== "undefined"
? buildShareableUrl(window.location.origin, target, manualType) ? buildShareableUrl(window.location.origin, target, manualType)

View File

@@ -4,11 +4,18 @@ import { useState, useEffect, useRef } from "react";
import { onPromise, preventDefault } from "@/lib/misc"; import { onPromise, preventDefault } from "@/lib/misc";
import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas"; import type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
import { TargetTypeEnum } from "@/rdap/schemas"; import { TargetTypeEnum } from "@/rdap/schemas";
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons"; import {
import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes"; MagnifyingGlassIcon,
ReloadIcon,
LockClosedIcon,
ExclamationTriangleIcon,
CrossCircledIcon,
} from "@radix-ui/react-icons";
import { TextField, Select, Flex, IconButton, Badge, Tooltip, Text } from "@radix-ui/themes";
import type { Maybe } from "true-myth"; import type { Maybe } from "true-myth";
import { placeholders } from "@/rdap/constants"; import { placeholders } from "@/rdap/constants";
import ShareButton from "@/components/ShareButton"; import ShareButton from "@/components/ShareButton";
import type { TldValidationResult } from "@/rdap/services/tld-validation";
/** /**
* Props for the LookupInput component. * Props for the LookupInput component.
@@ -38,6 +45,10 @@ type LookupInputProps = {
* Optional shareable URL to display in the share button. Only shown when provided. * Optional shareable URL to display in the share button. Only shown when provided.
*/ */
shareableUrl?: string; shareableUrl?: string;
/**
* TLD validation result for domain inputs. Shows warning/error icon when TLD is invalid or unavailable.
*/
tldValidation?: TldValidationResult | null;
}; };
const LookupInput: FunctionComponent<LookupInputProps> = ({ const LookupInput: FunctionComponent<LookupInputProps> = ({
@@ -46,6 +57,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
onChange, onChange,
detectedType, detectedType,
shareableUrl, shareableUrl,
tldValidation,
}: LookupInputProps) => { }: LookupInputProps) => {
const { register, handleSubmit, getValues } = useForm<SubmitProps>({ const { register, handleSubmit, getValues } = useForm<SubmitProps>({
defaultValues: { defaultValues: {
@@ -222,8 +234,42 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
)} )}
</IconButton> </IconButton>
</TextField.Slot> </TextField.Slot>
{shareableUrl && ( {tldValidation && tldValidation.type !== "valid" && (
<TextField.Slot side="right"> <TextField.Slot side="right">
<Tooltip
content={
tldValidation.type === "no-rdap" ? (
<Text size="2">
The TLD <strong>.{tldValidation.tld}</strong> exists
but is not available in the IANA RDAP registry. The
query will not return results.
</Text>
) : (
<Text size="2">
The TLD <strong>.{tldValidation.tld}</strong> is not
recognized as a valid top-level domain.
</Text>
)
}
>
{tldValidation.type === "no-rdap" ? (
<ExclamationTriangleIcon
width="16"
height="16"
style={{ color: "var(--orange-9)" }}
/>
) : (
<CrossCircledIcon
width="16"
height="16"
style={{ color: "var(--red-9)" }}
/>
)}
</Tooltip>
</TextField.Slot>
)}
{shareableUrl && (
<TextField.Slot side="right" pr="3">
<ShareButton url={shareableUrl} /> <ShareButton url={shareableUrl} />
</TextField.Slot> </TextField.Slot>
)} )}

View File

@@ -13,6 +13,7 @@ import {
} from "@/rdap/services/type-detection"; } from "@/rdap/services/type-detection";
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query"; import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query";
import { useTelemetry } from "@/contexts/TelemetryContext"; import { useTelemetry } from "@/contexts/TelemetryContext";
import { validateDomainTld, type TldValidationResult } from "@/rdap/services/tld-validation";
export type WarningHandler = (warning: { message: string }) => void; export type WarningHandler = (warning: { message: string }) => void;
export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void; export type UrlUpdateHandler = (target: string, manuallySelectedType: TargetType | null) => void;
@@ -31,6 +32,9 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
// Used by a callback on LookupInput to forcibly set the type of the lookup. // Used by a callback on LookupInput to forcibly set the type of the lookup.
const [currentType, setTargetType] = useState<TargetType | null>(null); const [currentType, setTargetType] = useState<TargetType | null>(null);
// TLD validation state for real-time warnings
const [tldValidation, setTldValidation] = useState<TldValidationResult | null>(null);
// Used to allow repeatable lookups when weird errors happen. // Used to allow repeatable lookups when weird errors happen.
const repeatableRef = useRef<string>(""); const repeatableRef = useRef<string>("");
@@ -55,6 +59,45 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
detectType().catch(console.error); detectType().catch(console.error);
}, [debouncedTarget, currentType, getTypeEasy]); }, [debouncedTarget, currentType, getTypeEasy]);
// Validate TLD in real-time for domain inputs
useEffect(() => {
const validateTld = async () => {
// Clear validation when input is empty
if (debouncedTarget.length === 0) {
setTldValidation(null);
return;
}
// Only validate domains
const isDomain =
currentType === "domain" ||
(currentType === null && uriType.mapOr(false, (t) => t === "domain"));
if (!isDomain) {
setTldValidation(null);
return;
}
// Perform validation
const result = await validateDomainTld(debouncedTarget);
setTldValidation(result);
// Track telemetry for warnings/errors
if (result.type !== "valid") {
track({
name: "user_interaction",
properties: {
action: "tld_warning_shown",
component: "LookupInput",
value: result.type,
},
});
}
};
validateTld().catch(console.error);
}, [debouncedTarget, currentType, uriType, track]);
useEffect(() => { useEffect(() => {
const preload = async () => { const preload = async () => {
if (uriType.isNothing) return; if (uriType.isNothing) return;
@@ -229,6 +272,7 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
currentType: uriType, currentType: uriType,
manualType: currentType, manualType: currentType,
getType: getTypeEasy, getType: getTypeEasy,
tldValidation,
}; };
}; };

View File

@@ -1,6 +1,19 @@
import type { ZodSchema } from "zod"; import type { ZodSchema } from "zod";
import { Result } from "true-myth"; import { Result } from "true-myth";
/**
* Custom error for 404 Not Found responses that includes the URL for context.
*/
export class NotFoundError extends Error {
constructor(
message: string,
public url: string
) {
super(message);
this.name = "NotFoundError";
}
}
/** /**
* Fetch and parse RDAP data from a URL * Fetch and parse RDAP data from a URL
* @param url - The URL to fetch * @param url - The URL to fetch
@@ -63,8 +76,9 @@ export async function getAndParse<T>(
case 404: case 404:
return Result.err( return Result.err(
new Error( new NotFoundError(
"The registry indicated that the resource requested could not be found; the resource either does not exist, or is something that the registry does not track (i.e. this software queried incorrectly, which is unlikely)." "The registry indicated that the resource requested could not be found.",
url
) )
); );
case 500: case 500:

View File

@@ -8,7 +8,7 @@ import {
import { Result } from "true-myth"; import { Result } from "true-myth";
import { loadBootstrap } from "@/rdap/services/registry"; import { loadBootstrap } from "@/rdap/services/registry";
import { getRegistryURL } from "@/rdap/services/resolver"; import { getRegistryURL } from "@/rdap/services/resolver";
import { getAndParse } from "@/rdap/services/api"; import { getAndParse, NotFoundError } from "@/rdap/services/api";
import type { ParsedGeneric } from "@/rdap/components/RdapObjectRouter"; import type { ParsedGeneric } from "@/rdap/components/RdapObjectRouter";
// An array of schemas to try and parse unknown JSON data with. // An array of schemas to try and parse unknown JSON data with.
@@ -74,21 +74,35 @@ export async function executeRdapQuery(
// Block scoped case to allow url const reuse // Block scoped case to allow url const reuse
case "ip4": { case "ip4": {
await loadBootstrap("ip4"); await loadBootstrap("ip4");
const url = getRegistryURL(targetType, target, queryParams); const url = await getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(`The IP address "${target}" was not found in the registry.`)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "ip6": { case "ip6": {
await loadBootstrap("ip6"); await loadBootstrap("ip6");
const url = getRegistryURL(targetType, target, queryParams); const url = await getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(`The IP address "${target}" was not found in the registry.`)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "domain": { case "domain": {
await loadBootstrap("domain"); await loadBootstrap("domain");
const url = getRegistryURL(targetType, target, queryParams); const url = await getRegistryURL(targetType, target, queryParams);
// HTTP // HTTP
if (url.startsWith("http://") && url != repeatableUrl) { if (url.startsWith("http://") && url != repeatableUrl) {
@@ -102,19 +116,37 @@ export async function executeRdapQuery(
); );
} }
const result = await getAndParse<Domain>(url, DomainSchema, followReferral); const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(
`The domain "${target}" was not found in the registry. It may not be registered or may have expired.`
)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "autnum": { case "autnum": {
await loadBootstrap("autnum"); await loadBootstrap("autnum");
const url = getRegistryURL(targetType, target, queryParams); const url = await getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<AutonomousNumber>( const result = await getAndParse<AutonomousNumber>(
url, url,
AutonomousNumberSchema, AutonomousNumberSchema,
followReferral followReferral
); );
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(
`The autonomous system number "${target}" was not found in the registry.`
)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "tld": { case "tld": {
@@ -127,7 +159,14 @@ export async function executeRdapQuery(
const baseUrl = `https://root.rdap.org/domain/${value}`; const baseUrl = `https://root.rdap.org/domain/${value}`;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
const result = await getAndParse<Domain>(url, DomainSchema, followReferral); const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(`The TLD "${target}" was not found in the root zone.`)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "url": { case "url": {
@@ -164,9 +203,16 @@ export async function executeRdapQuery(
} }
case "entity": { case "entity": {
await loadBootstrap("entity"); await loadBootstrap("entity");
const url = getRegistryURL(targetType, target, queryParams); const url = await getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<Entity>(url, EntitySchema, followReferral); const result = await getAndParse<Entity>(url, EntitySchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) {
if (result.error instanceof NotFoundError) {
return Result.err(
new Error(`The entity "${target}" was not found in the registry.`)
);
}
return Result.err(result.error);
}
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "registrar": case "registrar":

View File

@@ -2,6 +2,7 @@ import type { RootRegistryType } from "@/rdap/schemas";
import { getCachedRegistry } from "@/rdap/services/registry"; import { getCachedRegistry } from "@/rdap/services/registry";
import { domainMatchPredicate, getBestURL } from "@/rdap/utils"; import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network"; import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation";
export interface URLQueryParams { export interface URLQueryParams {
jsContact?: boolean; jsContact?: boolean;
@@ -11,11 +12,11 @@ export interface URLQueryParams {
/** /**
* Resolve the RDAP URL for a given registry type and lookup target * Resolve the RDAP URL for a given registry type and lookup target
*/ */
export function getRegistryURL( export async function getRegistryURL(
type: RootRegistryType, type: RootRegistryType,
lookupTarget: string, lookupTarget: string,
queryParams?: URLQueryParams queryParams?: URLQueryParams
): string { ): Promise<string> {
const bootstrap = getCachedRegistry(type); const bootstrap = getCachedRegistry(type);
if (bootstrap == null) if (bootstrap == null)
throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`); throw new Error(`Cannot acquire RDAP URL without bootstrap data for ${type} lookup.`);
@@ -23,7 +24,7 @@ export function getRegistryURL(
let url: string | null = null; let url: string | null = null;
typeSwitch: switch (type) { typeSwitch: switch (type) {
case "domain": case "domain": {
for (const bootstrapItem of bootstrap.services) { for (const bootstrapItem of bootstrap.services) {
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) { if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
// min length of 1 is validated in zod schema // min length of 1 is validated in zod schema
@@ -31,7 +32,31 @@ export function getRegistryURL(
break typeSwitch; break typeSwitch;
} }
} }
throw new Error(`No matching domain found.`);
// Domain not found in RDAP registry - provide detailed error
const tld = extractTld(lookupTarget);
if (!tld) {
throw new Error(
`Invalid domain format: "${lookupTarget}". Expected format: example.com`
);
}
// Validate TLD to provide context-specific error
const validation = await validateDomainTld(lookupTarget);
if (validation.type === "invalid") {
throw new Error(
`The TLD ".${validation.tld}" is not recognized as a valid top-level domain. Please verify the domain name is correct.`
);
} else if (validation.type === "no-rdap") {
throw new Error(
`The TLD ".${validation.tld}" exists but is not available in the IANA RDAP registry. This TLD does not support RDAP lookups.`
);
}
// Fallback (should not reach here given validation logic)
throw new Error(`No RDAP server found for domain "${lookupTarget}".`);
}
case "ip4": { case "ip4": {
// Extract the IP address without CIDR suffix for matching // Extract the IP address without CIDR suffix for matching
const [ipAddress] = lookupTarget.split("/"); const [ipAddress] = lookupTarget.split("/");

View File

@@ -0,0 +1,122 @@
/**
* TLD validation service
* Validates domain TLDs against:
* 1. Complete IANA TLD list (all valid TLDs)
* 2. RDAP DNS registry (TLDs with RDAP support)
*/
export type TldValidationResult =
| { type: "valid" }
| { type: "no-rdap"; tld: string }
| { type: "invalid"; tld: string };
// Cache for IANA TLD list
let tldListCache: Set<string> | null = null;
let tldListCacheExpiry: number | null = null;
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
const IANA_TLD_LIST_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt";
/**
* Load the complete IANA TLD list from official source
*/
async function loadIanaTldList(): Promise<Set<string>> {
// Check in-memory cache first
const now = Date.now();
if (tldListCache && tldListCacheExpiry && now < tldListCacheExpiry) {
return tldListCache;
}
try {
const response = await fetch(IANA_TLD_LIST_URL);
if (!response.ok) {
throw new Error(`Failed to fetch IANA TLD list: ${response.statusText}`);
}
const text = await response.text();
// Parse file - format is one TLD per line, comments start with #
const tlds = text
.split("\n")
.map((line) => line.trim().toLowerCase())
.filter((line) => line && !line.startsWith("#"));
tldListCache = new Set(tlds);
tldListCacheExpiry = now + CACHE_TTL;
return tldListCache;
} catch (error) {
// If fetch fails, return empty set to fail gracefully
console.error("Failed to load IANA TLD list:", error);
return new Set();
}
}
/**
* Extract TLD from domain (rightmost label after final dot)
*/
export function extractTld(domain: string): string | null {
const parts = domain.split(".");
if (parts.length < 2) {
return null;
}
const tld = parts[parts.length - 1];
return tld ? tld.toLowerCase() : null;
}
/**
* Check if TLD exists in official IANA TLD list
*/
async function isValidTld(tld: string): Promise<boolean> {
const tldList = await loadIanaTldList();
// If list couldn't be loaded, assume valid to avoid false positives
if (tldList.size === 0) {
return true;
}
return tldList.has(tld.toLowerCase());
}
/**
* Check if TLD has RDAP support in IANA DNS registry
*/
async function isRdapAvailable(tld: string): Promise<boolean> {
try {
const { getRegistry } = await import("./registry");
const registry = await getRegistry("domain");
// Check if TLD appears in any registry service
return registry.services.some((service) =>
service[0].some((registryTld) => registryTld.toLowerCase() === tld.toLowerCase())
);
} catch (error) {
// If registry can't be loaded, assume no RDAP available
console.error("Failed to check RDAP availability:", error);
return false;
}
}
/**
* Validate domain TLD against IANA list and RDAP registry
* Returns validation result indicating if TLD is valid, has RDAP, or is invalid
*/
export async function validateDomainTld(domain: string): Promise<TldValidationResult> {
const tld = extractTld(domain);
if (!tld) {
return { type: "invalid", tld: domain };
}
const [isValid, hasRdap] = await Promise.all([isValidTld(tld), isRdapAvailable(tld)]);
if (!isValid) {
return { type: "invalid", tld };
}
if (!hasRdap) {
return { type: "no-rdap", tld };
}
return { type: "valid" };
}