21 Commits

Author SHA1 Message Date
renovate[bot]
d95a3e7574 chore(deps): update pnpm to v10 2025-11-10 08:44:35 +00:00
Ryan Walters
13fc05226d 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.
2025-11-06 15:40:28 -06:00
Ryan Walters
71ddaadaa0 chore(config): reduce renovate commit frequency to weekly
Consolidates dependency updates to reduce noise:
- Schedule changed to weekly (Monday mornings only)
- All non-major updates grouped into single weekly PR
- Major updates still get individual PRs for review
- Security updates remain immediate (unchanged)

This reduces commit frequency from 10-15/week to ~1-2/week while
maintaining test requirements and security responsiveness.
2025-11-05 23:26:28 -06:00
renovate[bot]
b50e575946 chore(deps): update testing packages to v4.0.6 (#18)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-06 04:52:21 +00:00
renovate[bot]
27fb354800 chore(deps): update dependency @posthog/nextjs-config to v1.3.9 (#17)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 07:54:58 +00:00
renovate[bot]
b7c2a6a14c chore(deps): update dependency react-hook-form to v7.66.0 (#16)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 04:37:40 +00:00
renovate[bot]
60677a2efa chore(deps): update testing packages to v4.0.5 (#15)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 04:58:43 +00:00
renovate[bot]
4d03b92688 chore(deps): update dependency sass to v1.93.3 (#14)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 01:42:05 +00:00
renovate[bot]
f59293167b chore(deps): update dependency happy-dom to v20.0.10 (#13)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 20:31:20 +00:00
renovate[bot]
507f903139 chore(deps): update dependency @types/node to v24.9.2 (#12)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 18:08:09 +00:00
renovate[bot]
1c80fb3cd6 chore(deps): update dependency @posthog/nextjs-config to v1.3.8 (#11)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 13:13:44 +00:00
renovate[bot]
ae4ba813b9 chore(deps): update dependency @mantine/hooks to v8.3.6 (#10)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-02 10:32:59 +00:00
ed1b1e1598 chore(config): format renovate config for consistency
Reformat .github/renovate.json arrays to single-line format for
better readability and consistency with common JSON formatting
practices.
2025-10-27 02:24:44 -05:00
renovate[bot]
19e646ca0d chore(deps): update tailwind css packages to v4.1.16 (#9)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 05:37:31 +00:00
renovate[bot]
fd6c23e44f chore(config): migrate config .github/renovate.json 2025-10-26 19:42:53 -05:00
renovate[bot]
2d6366aca1 chore(deps): update github/codeql-action action to v4 2025-10-26 19:41:45 -05:00
renovate[bot]
08ac97861c chore(deps): update actions/setup-node action to v6 2025-10-26 19:41:38 -05:00
renovate[bot]
c1955e2c6e chore(deps): update actions/checkout action to v5 2025-10-26 19:41:08 -05:00
b8b4b619ce ci: use pnpm version from packageManager field
Remove explicit PNPM_VERSION environment variable and version
specification from pnpm/action-setup@v4. The action now
automatically reads the version from package.json's
packageManager field, eliminating version duplication.
2025-10-26 19:38:16 -05:00
renovate[bot]
d296068f9b chore(deps): update dependency lint-staged to v16 (#7)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 13:28:06 +00:00
renovate[bot]
0af52f5d10 chore(deps): update commitlint monorepo to v20 (#6)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 09:34:10 +00:00
12 changed files with 1004 additions and 619 deletions

52
.github/renovate.json vendored
View File

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

View File

@@ -12,7 +12,6 @@ permissions:
env:
NODE_VERSION: "20"
PNPM_VERSION: "9.0.0"
jobs:
# Code quality checks
@@ -22,15 +21,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
@@ -51,15 +48,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
@@ -96,15 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
@@ -124,15 +117,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
@@ -155,7 +146,7 @@ jobs:
exit-code: 0
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: "trivy-results.sarif"

View File

@@ -42,8 +42,8 @@
},
"devDependencies": {
"@codecov/vite-plugin": "^1.9.1",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@commitlint/cli": "^20.0.0",
"@commitlint/config-conventional": "^20.0.0",
"@posthog/nextjs-config": "^1.3.6",
"@tailwindcss/postcss": "^4.1.15",
"@testing-library/jest-dom": "^6.9.1",
@@ -59,7 +59,7 @@
"eslint-config-next": "15.5.6",
"happy-dom": "^20.0.8",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"lint-staged": "^16.0.0",
"postcss": "^8.4.14",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
@@ -70,5 +70,5 @@
"ct3aMetadata": {
"initVersion": "7.2.0"
},
"packageManager": "pnpm@9.15.9"
"packageManager": "pnpm@10.20.0"
}

975
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

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]
);
const { error, target, setTarget, setTargetType, submit, currentType, manualType } = useLookup(
undefined,
handleUrlUpdate
);
const {
error,
target,
setTarget,
setTargetType,
submit,
currentType,
manualType,
tldValidation,
} = useLookup(undefined, handleUrlUpdate);
// Parse URL parameters on mount and auto-execute query if present
useEffect(() => {
@@ -143,6 +149,7 @@ const Index: NextPage = () => {
<LookupInput
isLoading={isLoading}
detectedType={currentType}
tldValidation={tldValidation}
shareableUrl={
response.isJust && target && typeof window !== "undefined"
? 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 type { SimplifiedTargetType, SubmitProps, TargetType } from "@/rdap/schemas";
import { TargetTypeEnum } from "@/rdap/schemas";
import { MagnifyingGlassIcon, ReloadIcon, LockClosedIcon } from "@radix-ui/react-icons";
import { TextField, Select, Flex, IconButton, Badge } from "@radix-ui/themes";
import {
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 { placeholders } from "@/rdap/constants";
import ShareButton from "@/components/ShareButton";
import type { TldValidationResult } from "@/rdap/services/tld-validation";
/**
* Props for the LookupInput component.
@@ -38,6 +45,10 @@ type LookupInputProps = {
* Optional shareable URL to display in the share button. Only shown when provided.
*/
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> = ({
@@ -46,6 +57,7 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
onChange,
detectedType,
shareableUrl,
tldValidation,
}: LookupInputProps) => {
const { register, handleSubmit, getValues } = useForm<SubmitProps>({
defaultValues: {
@@ -222,8 +234,42 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
)}
</IconButton>
</TextField.Slot>
{shareableUrl && (
{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 && (
<TextField.Slot side="right" pr="3">
<ShareButton url={shareableUrl} />
</TextField.Slot>
)}

View File

@@ -13,6 +13,7 @@ import {
} from "@/rdap/services/type-detection";
import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/query";
import { useTelemetry } from "@/contexts/TelemetryContext";
import { validateDomainTld, type TldValidationResult } from "@/rdap/services/tld-validation";
export type WarningHandler = (warning: { message: string }) => 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.
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.
const repeatableRef = useRef<string>("");
@@ -55,6 +59,45 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
detectType().catch(console.error);
}, [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(() => {
const preload = async () => {
if (uriType.isNothing) return;
@@ -229,6 +272,7 @@ const useLookup = (warningHandler?: WarningHandler, urlUpdateHandler?: UrlUpdate
currentType: uriType,
manualType: currentType,
getType: getTypeEasy,
tldValidation,
};
};

View File

@@ -1,6 +1,19 @@
import type { ZodSchema } from "zod";
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
* @param url - The URL to fetch
@@ -63,8 +76,9 @@ export async function getAndParse<T>(
case 404:
return Result.err(
new Error(
"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)."
new NotFoundError(
"The registry indicated that the resource requested could not be found.",
url
)
);
case 500:

View File

@@ -8,7 +8,7 @@ import {
import { Result } from "true-myth";
import { loadBootstrap } from "@/rdap/services/registry";
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";
// 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
case "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);
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 });
}
case "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);
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 });
}
case "domain": {
await loadBootstrap("domain");
const url = getRegistryURL(targetType, target, queryParams);
const url = await getRegistryURL(targetType, target, queryParams);
// HTTP
if (url.startsWith("http://") && url != repeatableUrl) {
@@ -102,19 +116,37 @@ export async function executeRdapQuery(
);
}
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 });
}
case "autnum": {
await loadBootstrap("autnum");
const url = getRegistryURL(targetType, target, queryParams);
const url = await getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<AutonomousNumber>(
url,
AutonomousNumberSchema,
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 });
}
case "tld": {
@@ -127,7 +159,14 @@ export async function executeRdapQuery(
const baseUrl = `https://root.rdap.org/domain/${value}`;
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
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 });
}
case "url": {
@@ -164,9 +203,16 @@ export async function executeRdapQuery(
}
case "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);
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 });
}
case "registrar":

View File

@@ -2,6 +2,7 @@ import type { RootRegistryType } from "@/rdap/schemas";
import { getCachedRegistry } from "@/rdap/services/registry";
import { domainMatchPredicate, getBestURL } from "@/rdap/utils";
import { ipv4InCIDR, ipv6InCIDR, asnInRange } from "@/lib/network";
import { extractTld, validateDomainTld } from "@/rdap/services/tld-validation";
export interface URLQueryParams {
jsContact?: boolean;
@@ -11,11 +12,11 @@ export interface URLQueryParams {
/**
* Resolve the RDAP URL for a given registry type and lookup target
*/
export function getRegistryURL(
export async function getRegistryURL(
type: RootRegistryType,
lookupTarget: string,
queryParams?: URLQueryParams
): string {
): Promise<string> {
const bootstrap = getCachedRegistry(type);
if (bootstrap == null)
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;
typeSwitch: switch (type) {
case "domain":
case "domain": {
for (const bootstrapItem of bootstrap.services) {
if (bootstrapItem[0].some(domainMatchPredicate(lookupTarget))) {
// min length of 1 is validated in zod schema
@@ -31,7 +32,31 @@ export function getRegistryURL(
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": {
// Extract the IP address without CIDR suffix for matching
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" };
}