1 Commits

Author SHA1 Message Date
renovate[bot]
49a269df14 chore(deps): update pnpm to v10 2025-11-06 04:54:41 +00:00
10 changed files with 71 additions and 544 deletions

52
.github/renovate.json vendored
View File

@@ -7,7 +7,7 @@
":automergeDigest", ":automergeDigest",
":automergeMinor" ":automergeMinor"
], ],
"schedule": ["before 5am on monday"], "schedule": ["after 10pm every weekday", "before 5am every weekday", "every weekend"],
"timezone": "America/Chicago", "timezone": "America/Chicago",
"prConcurrentLimit": 3, "prConcurrentLimit": 3,
"prCreation": "not-pending", "prCreation": "not-pending",
@@ -20,23 +20,61 @@
}, },
"packageRules": [ "packageRules": [
{ {
"description": "Group all non-major dependency updates together", "description": "Automerge dev dependencies",
"groupName": "all non-major dependencies", "matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["minor", "patch", "digest"],
"automerge": true, "automerge": true,
"automergeType": "pr", "automergeType": "pr",
"minimumReleaseAge": "3 days" "minimumReleaseAge": "3 days"
}, },
{ {
"description": "Major updates get individual PRs for review", "description": "Automerge TypeScript type packages",
"matchUpdateTypes": ["major"], "automerge": true,
"automerge": false, "automergeType": "pr",
"matchPackageNames": ["/^@types//"]
},
{
"description": "Group ESLint packages together",
"groupName": "eslint packages",
"automerge": true,
"matchPackageNames": ["/^eslint/", "/^@typescript-eslint//"]
},
{
"description": "Group testing packages together",
"groupName": "testing packages",
"automerge": true,
"matchPackageNames": ["/^vitest/", "/^@vitest//", "/^@testing-library//"]
},
{
"description": "Group Next.js related packages",
"matchPackageNames": ["next", "eslint-config-next"],
"groupName": "Next.js packages",
"minimumReleaseAge": "7 days"
},
{
"description": "Group React packages",
"matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
"groupName": "React packages",
"minimumReleaseAge": "7 days" "minimumReleaseAge": "7 days"
}, },
{ {
"description": "Pin Node.js major versions", "description": "Pin Node.js major versions",
"matchPackageNames": ["node"], "matchPackageNames": ["node"],
"enabled": false "enabled": false
},
{
"description": "Group Tailwind CSS packages",
"groupName": "Tailwind CSS packages",
"matchPackageNames": [
"/^tailwindcss/",
"/^@tailwindcss//",
"/prettier-plugin-tailwindcss/"
]
},
{
"description": "Group font packages",
"groupName": "font packages",
"automerge": true,
"matchPackageNames": ["/^@fontsource/"]
} }
], ],
"postUpdateOptions": ["pnpmDedupe"], "postUpdateOptions": ["pnpmDedupe"],

View File

@@ -70,5 +70,5 @@
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.2.0" "initVersion": "7.2.0"
}, },
"packageManager": "pnpm@9.15.9" "packageManager": "pnpm@10.20.0"
} }

View File

@@ -1,207 +0,0 @@
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,16 +30,10 @@ const Index: NextPage = () => {
[router] [router]
); );
const { const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup(
error, undefined,
target, handleUrlUpdate
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(() => {
@@ -149,7 +143,6 @@ 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,18 +4,11 @@ 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 { import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
MagnifyingGlassIcon, import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes";
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.
@@ -45,10 +38,6 @@ 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> = ({
@@ -57,7 +46,6 @@ 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: {
@@ -234,42 +222,8 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
)} )}
</IconButton> </IconButton>
</TextField.Slot> </TextField.Slot>
{tldValidation && tldValidation.type !== "valid" && (
<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 && ( {shareableUrl && (
<TextField.Slot side="right" pr="3"> <TextField.Slot side="right">
<ShareButton url={shareableUrl} /> <ShareButton url={shareableUrl} />
</TextField.Slot> </TextField.Slot>
)} )}

View File

@@ -13,7 +13,6 @@ 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;
@@ -32,9 +31,6 @@ 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>("");
@@ -59,45 +55,6 @@ 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;
@@ -272,7 +229,6 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
currentType: uriType, currentType: uriType,
manualType: currentType, manualType: currentType,
getType: getTypeEasy, getType: getTypeEasy,
tldValidation,
}; };
}; };

View File

@@ -1,19 +1,6 @@
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
@@ -76,9 +63,8 @@ export async function getAndParse<T>(
case 404: case 404:
return Result.err( return Result.err(
new NotFoundError( new Error(
"The registry indicated that the resource requested could not be found.", "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)."
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, NotFoundError } from "@/rdap/services/api"; import { getAndParse } 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,35 +74,21 @@ 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 = await getRegistryURL(targetType, target, queryParams); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) { if (result.isErr) return Result.err(result.error);
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 = await getRegistryURL(targetType, target, queryParams); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) { if (result.isErr) return Result.err(result.error);
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 = await getRegistryURL(targetType, target, queryParams); const url = getRegistryURL(targetType, target, queryParams);
// HTTP // HTTP
if (url.startsWith("http://") && url != repeatableUrl) { if (url.startsWith("http://") && url != repeatableUrl) {
@@ -116,37 +102,19 @@ export async function executeRdapQuery(
); );
} }
const result = await getAndParse<Domain>(url, DomainSchema, followReferral); const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
if (result.isErr) { if (result.isErr) return Result.err(result.error);
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 = await getRegistryURL(targetType, target, queryParams); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<AutonomousNumber>( const result = await getAndParse<AutonomousNumber>(
url, url,
AutonomousNumberSchema, AutonomousNumberSchema,
followReferral followReferral
); );
if (result.isErr) { if (result.isErr) return Result.err(result.error);
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": {
@@ -159,14 +127,7 @@ 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) { if (result.isErr) return Result.err(result.error);
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": {
@@ -203,16 +164,9 @@ export async function executeRdapQuery(
} }
case "entity": { case "entity": {
await loadBootstrap("entity"); await loadBootstrap("entity");
const url = await getRegistryURL(targetType, target, queryParams); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<Entity>(url, EntitySchema, followReferral); const result = await getAndParse<Entity>(url, EntitySchema, followReferral);
if (result.isErr) { if (result.isErr) return Result.err(result.error);
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,7 +2,6 @@ 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;
@@ -12,11 +11,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 async function getRegistryURL( export function getRegistryURL(
type: RootRegistryType, type: RootRegistryType,
lookupTarget: string, lookupTarget: string,
queryParams?: URLQueryParams queryParams?: URLQueryParams
): Promise<string> { ): 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.`);
@@ -24,7 +23,7 @@ export async 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
@@ -32,31 +31,7 @@ export async 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

@@ -1,122 +0,0 @@
/**
* 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" };
}