feat: implement JSContact and follow referral features

Add comprehensive support for JSContact (RFC 9553) format as an
alternative
to vCard in RDAP responses, along with HTTP redirect handling.

Features:
- Add query parameter support for jsContact and followReferral options
- Implement complete JSContact Zod schemas and TypeScript types
- Create JSContactDisplay component with full contact data rendering
- Add ContactDisplay wrapper for automatic format detection
- Wire up form values through lookup hook to RDAP requests
- Implement HTTP redirect handling with manual/follow modes
- Update EntityCard and EntitiesSection to support both formats
This commit is contained in:
2025-10-22 22:11:43 -05:00
parent a0d3933c02
commit abbde855ed
9 changed files with 563 additions and 30 deletions

View File

@@ -0,0 +1,30 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { Entity } from "@/rdap/schemas";
import VCardDisplay from "./VCardDisplay";
import JSContactDisplay from "./JSContactDisplay";
export type ContactDisplayProps = {
entity: Entity;
};
/**
* Wrapper component that auto-detects contact format and displays appropriately.
* Supports both vCard (jCard) and JSContact formats in RDAP responses.
*/
const ContactDisplay: FunctionComponent<ContactDisplayProps> = ({ entity }) => {
// Check for JSContact format first (preferred modern format)
if (entity.jscard) {
return <JSContactDisplay jscard={entity.jscard} />;
}
// Fall back to vCard format if available
if (entity.vcardArray) {
return <VCardDisplay vcardArray={entity.vcardArray} />;
}
// No contact information available
return null;
};
export default ContactDisplay;

View File

@@ -2,7 +2,7 @@ import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Entity, RdapStatusType } from "@/rdap/schemas"; import type { Entity, RdapStatusType } from "@/rdap/schemas";
import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes"; import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes";
import VCardDisplay from "@/rdap/components/VCardDisplay"; import ContactDisplay from "@/rdap/components/ContactDisplay";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
@@ -84,9 +84,9 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
)} )}
</DataList.Root> </DataList.Root>
{entity.vcardArray && ( {(entity.vcardArray || entity.jscard) && (
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<VCardDisplay vcardArray={entity.vcardArray} /> <ContactDisplay entity={entity} />
</Flex> </Flex>
)} )}

View File

@@ -5,7 +5,7 @@ import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
import AbstractCard from "@/components/AbstractCard"; import AbstractCard from "@/components/AbstractCard";
import Property from "@/components/Property"; import Property from "@/components/Property";
import VCardDisplay from "@/rdap/components/VCardDisplay"; import ContactDisplay from "@/rdap/components/ContactDisplay";
import Events from "@/rdap/components/Events"; import Events from "@/rdap/components/Events";
import LinksSection from "@/rdap/components/LinksSection"; import LinksSection from "@/rdap/components/LinksSection";
import RemarksSection from "@/rdap/components/RemarksSection"; import RemarksSection from "@/rdap/components/RemarksSection";
@@ -102,9 +102,9 @@ const EntityCard: FunctionComponent<EntityCardProps> = ({ data, url }: EntityCar
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)} )}
{data.vcardArray && ( {(data.vcardArray || data.jscard) && (
<Property title="Contact Information"> <Property title="Contact Information">
<VCardDisplay vcardArray={data.vcardArray} /> <ContactDisplay entity={data} />
</Property> </Property>
)} )}
{data.entities && data.entities.length > 0 && ( {data.entities && data.entities.length > 0 && (

View File

@@ -0,0 +1,337 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { JSCard } from "@/rdap/schemas";
import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes";
import CopyButton from "@/components/CopyButton";
export type JSContactDisplayProps = {
jscard: JSCard;
};
/**
* Display component for JSContact (RFC 9553) contact cards in RDAP responses.
* JSContact is a JSON-native alternative to vCard/jCard.
*/
const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => {
// Extract full name or build from components
const displayName = jscard.name?.full || "";
// Extract organization names
const organizations = jscard.organizations
? Object.values(jscard.organizations)
.map((org) => {
if (org.name && org.units) {
return `${org.name} > ${org.units.map((u: { name: string }) => u.name).join(" > ")}`;
}
return (
org.name ||
org.units?.map((u: { name: string }) => u.name).join(" > ") ||
""
);
})
.filter(Boolean)
: [];
// Extract titles/roles - cast to proper type
const titles = jscard.titles
? (Object.values(jscard.titles) as Array<{
name: string;
kind?: "title" | "role";
organizationId?: string;
}>)
: [];
// Extract emails - cast to proper type
const emails = jscard.emails
? (Object.entries(jscard.emails) as Array<
[
string,
{
address: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract phones - cast to proper type
const phones = jscard.phones
? (Object.entries(jscard.phones) as Array<
[
string,
{
number: string;
features?: Record<string, boolean>;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract addresses - cast to proper type
const addresses = jscard.addresses
? (Object.entries(jscard.addresses) as Array<
[
string,
{
components?: Array<{ kind?: string; value: string }>;
full?: string;
countryCode?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
pref?: number;
},
]
>)
: [];
// Extract online services - cast to proper type
const onlineServices = jscard.onlineServices
? (Object.entries(jscard.onlineServices) as Array<
[
string,
{
service?: string;
uri?: string;
user?: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract links - cast to proper type
const links = jscard.links
? (Object.entries(jscard.links) as Array<
[
string,
{
uri: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
return (
<Table.Root size="2" variant="surface">
<Table.Body>
{displayName && (
<Table.Row>
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
<Table.Cell>
<Flex gap="2" align="center">
<Text>{displayName}</Text>
<CopyButton value={displayName} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{jscard.kind && jscard.kind !== "individual" && (
<Table.Row>
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
<Table.Cell>
<Badge variant="soft">{jscard.kind}</Badge>
</Table.Cell>
</Table.Row>
)}
{organizations.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{organizations.map((org, index) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">{org}</Code>
<CopyButton value={org} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{titles.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{titles.map((title, index) => (
<Flex key={index} align="center" gap="2">
<Text>{title.name}</Text>
{title.kind && title.kind !== "title" && (
<Badge size="1" variant="soft">
{title.kind}
</Badge>
)}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{emails.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{emails.map(([key, email]) => (
<Flex key={key} align="center" gap="2">
<Link href={`mailto:${email.address}`}>
<Code variant="ghost">{email.address}</Code>
</Link>
{email.label && (
<Badge size="1" variant="soft">
{email.label}
</Badge>
)}
{email.contexts &&
Object.keys(email.contexts).length > 0 && (
<Badge size="1" variant="outline">
{Object.keys(email.contexts).join(", ")}
</Badge>
)}
<CopyButton value={email.address} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{phones.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{phones.map(([key, phone]) => (
<Flex key={key} align="center" gap="2">
<Link href={`tel:${phone.number}`}>
<Code variant="ghost">{phone.number}</Code>
</Link>
{phone.features &&
Object.keys(phone.features).length > 0 && (
<Badge size="1" variant="soft">
{Object.keys(phone.features).join(", ")}
</Badge>
)}
{phone.label && (
<Badge size="1" variant="outline">
{phone.label}
</Badge>
)}
<CopyButton value={phone.number} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{addresses.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="2">
{addresses.map(([key, addr]) => {
// Use full address if available, otherwise build from components
const addressText =
addr.full ||
(addr.components
? addr.components.map((c) => c.value).join(", ")
: "");
const details = [addr.countryCode, addr.timeZone].filter(
Boolean
);
return (
<Flex key={key} direction="column" gap="1">
<Text size="2">{addressText}</Text>
{details.length > 0 && (
<Flex gap="1">
{details.map((detail, idx) => (
<Badge key={idx} size="1" variant="soft">
{detail}
</Badge>
))}
</Flex>
)}
</Flex>
);
})}
</Flex>
</Table.Cell>
</Table.Row>
)}
{onlineServices.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{onlineServices.map(([key, service]) => (
<Flex key={key} align="center" gap="2">
{service.uri && (
<Link
href={service.uri}
target="_blank"
rel="noreferrer"
>
<Code variant="ghost">
{service.service || service.user || service.uri}
</Code>
</Link>
)}
{!service.uri && (
<Code variant="ghost">
{service.service || service.user || key}
</Code>
)}
{service.service && (
<Badge size="1" variant="soft">
{service.service}
</Badge>
)}
{service.uri && <CopyButton value={service.uri} />}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{links.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{links.map(([key, link]) => (
<Flex key={key} align="center" gap="2">
<Link href={link.uri} target="_blank" rel="noreferrer">
<Code variant="ghost">{link.label || link.uri}</Code>
</Link>
<CopyButton value={link.uri} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
);
};
export default JSContactDisplay;

View File

@@ -160,15 +160,15 @@ const LookupInput: FunctionComponent<LookupInputProps> = ({
onFocus={() => { onFocus={() => {
isFocusedRef.current = true; isFocusedRef.current = true;
}} }}
onBlur={() => { {...register("target", {
required: true,
onBlur: () => {
// Don't clear focus state if we're loading (input is being disabled) // Don't clear focus state if we're loading (input is being disabled)
// so we can restore focus when loading completes // so we can restore focus when loading completes
if (!isLoading) { if (!isLoading) {
isFocusedRef.current = false; isFocusedRef.current = false;
} }
}} },
{...register("target", {
required: true,
onChange: () => { onChange: () => {
const targetValue = getValues("target"); const targetValue = getValues("target");
const oldIsEmpty = inputValue.trim() === ""; const oldIsEmpty = inputValue.trim() === "";

View File

@@ -79,7 +79,9 @@ const useLookup = (warningHandler?: WarningHandler) => {
}, [uriType, warningHandler]); }, [uriType, warningHandler]);
async function submitInternal( async function submitInternal(
target: string target: string,
requestJSContact: boolean,
followReferral: boolean
): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> { ): Promise<Result<{ data: ParsedGeneric; url: string }, Error>> {
if (target == null || target.length == 0) if (target == null || target.length == 0)
return Result.err(new Error("A target must be given in order to execute a lookup.")); return Result.err(new Error("A target must be given in order to execute a lookup."));
@@ -115,25 +117,28 @@ const useLookup = (warningHandler?: WarningHandler) => {
targetType = detectedType.value; targetType = detectedType.value;
} }
// Prepare query parameters for RDAP requests
const queryParams = { jsContact: requestJSContact, followReferral };
switch (targetType) { switch (targetType) {
// 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); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) 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); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<IpNetwork>(url, IpNetworkSchema); const result = await getAndParse<IpNetwork>(url, IpNetworkSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) 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); const url = getRegistryURL(targetType, target, queryParams);
// HTTP // HTTP
if (url.startsWith("http://") && url != repeatableRef.current) { if (url.startsWith("http://") && url != repeatableRef.current) {
@@ -146,23 +151,32 @@ const useLookup = (warningHandler?: WarningHandler) => {
) )
); );
} }
const result = await getAndParse<Domain>(url, DomainSchema); const result = await getAndParse<Domain>(url, DomainSchema, followReferral);
if (result.isErr) return Result.err(result.error); if (result.isErr) 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); const url = getRegistryURL(targetType, target, queryParams);
const result = await getAndParse<AutonomousNumber>(url, AutonomousNumberSchema); const result = await getAndParse<AutonomousNumber>(
url,
AutonomousNumberSchema,
followReferral
);
if (result.isErr) return Result.err(result.error); if (result.isErr) return Result.err(result.error);
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
case "tld": { case "tld": {
// remove the leading dot // remove the leading dot
const value = target.startsWith(".") ? target.slice(1) : target; const value = target.startsWith(".") ? target.slice(1) : target;
const url = `https://root.rdap.org/domain/${value}`; const params = new URLSearchParams();
const result = await getAndParse<Domain>(url, DomainSchema); if (requestJSContact) params.append("jsContact", "1");
if (followReferral) params.append("followReferral", "1");
const queryString = params.toString();
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) return Result.err(result.error);
return Result.ok({ data: result.value, url }); return Result.ok({ data: result.value, url });
} }
@@ -204,10 +218,14 @@ const useLookup = (warningHandler?: WarningHandler) => {
} }
} }
async function submit({ target }: SubmitProps): Promise<Maybe<MetaParsedGeneric>> { async function submit({
target,
requestJSContact,
followReferral,
}: SubmitProps): Promise<Maybe<MetaParsedGeneric>> {
try { try {
// target is already set in state, but it's also provided by the form callback, so we'll use it. // target is already set in state, but it's also provided by the form callback, so we'll use it.
const response = await submitInternal(target); const response = await submitInternal(target, requestJSContact, followReferral);
if (response.isErr) { if (response.isErr) {
setError(response.error.message); setError(response.error.message);

View File

@@ -86,6 +86,116 @@ export const RemarkSchema = z.object({
// Format: ["vcard", [properties...]] // Format: ["vcard", [properties...]]
export const VCardArraySchema = z.array(z.any()); export const VCardArraySchema = z.array(z.any());
// JSContact (RFC 9553) - JSON representation of contact data
// More structured than vCard, used as alternative in RDAP responses
export const JSContactNameComponentSchema = z.object({
kind: z
.enum([
"title",
"given",
"given2",
"surname",
"surname2",
"credential",
"generation",
"separator",
])
.optional(),
value: z.string(),
});
export const JSContactNameSchema = z.object({
full: z.string().optional(),
components: z.array(JSContactNameComponentSchema).optional(),
isOrdered: z.boolean().optional(),
sortAs: z.record(z.string(), z.string()).optional(),
});
export const JSContactEmailSchema = z.object({
address: z.string(), // addr-spec format per RFC5322
contexts: z.record(z.string(), z.boolean()).optional(),
pref: z.number().optional(),
label: z.string().optional(),
});
export const JSContactPhoneSchema = z.object({
number: z.string(), // URI or free text
features: z.record(z.string(), z.boolean()).optional(), // mobile, voice, text, video, etc.
contexts: z.record(z.string(), z.boolean()).optional(),
pref: z.number().optional(),
label: z.string().optional(),
});
export const JSContactAddressComponentSchema = z.object({
kind: z.string().optional(),
value: z.string(),
});
export const JSContactAddressSchema = z.object({
components: z.array(JSContactAddressComponentSchema).optional(),
full: z.string().optional(),
countryCode: z.string().optional(),
coordinates: z.string().optional(),
timeZone: z.string().optional(),
contexts: z.record(z.string(), z.boolean()).optional(),
pref: z.number().optional(),
});
export const JSContactOrganizationSchema = z.object({
name: z.string().optional(),
units: z.array(z.object({ name: z.string() })).optional(),
sortAs: z.record(z.string(), z.string()).optional(),
contexts: z.record(z.string(), z.boolean()).optional(),
});
export const JSContactTitleSchema = z.object({
name: z.string(),
kind: z.enum(["title", "role"]).optional(),
organizationId: z.string().optional(),
});
export const JSContactOnlineServiceSchema = z.object({
service: z.string().optional(),
uri: z.string().optional(),
user: z.string().optional(),
contexts: z.record(z.string(), z.boolean()).optional(),
pref: z.number().optional(),
label: z.string().optional(),
});
export const JSContactLinkSchema = z.object({
uri: z.string(),
contexts: z.record(z.string(), z.boolean()).optional(),
pref: z.number().optional(),
label: z.string().optional(),
});
// Main JSCard object (RFC 9553)
export const JSCardSchema = z
.object({
"@type": z.literal("Card"),
version: z.string(), // Should be "1.0"
uid: z.string(), // Unique identifier
created: z.string().optional(), // UTCDateTime
updated: z.string().optional(), // UTCDateTime
kind: z
.enum(["individual", "group", "org", "location", "device", "application"])
.optional(),
language: z.string().optional(),
name: JSContactNameSchema.optional(),
organizations: z.record(z.string(), JSContactOrganizationSchema).optional(),
titles: z.record(z.string(), JSContactTitleSchema).optional(),
emails: z.record(z.string(), JSContactEmailSchema).optional(),
phones: z.record(z.string(), JSContactPhoneSchema).optional(),
onlineServices: z.record(z.string(), JSContactOnlineServiceSchema).optional(),
addresses: z.record(z.string(), JSContactAddressSchema).optional(),
links: z.record(z.string(), JSContactLinkSchema).optional(),
// Allow additional properties for extensibility
// JSContact spec allows vendor-specific extensions
})
.passthrough();
export const IPAddressesSchema = z.object({ export const IPAddressesSchema = z.object({
v4: z.array(z.string()).optional(), v4: z.array(z.string()).optional(),
v6: z.array(z.string()).optional(), v6: z.array(z.string()).optional(),
@@ -166,6 +276,7 @@ const BaseEntitySchema = z.object({
objectClassName: z.literal("entity"), objectClassName: z.literal("entity"),
handle: z.string().optional(), handle: z.string().optional(),
vcardArray: VCardArraySchema.optional(), vcardArray: VCardArraySchema.optional(),
jscard: JSCardSchema.optional(),
roles: z.array(z.string()).optional(), roles: z.array(z.string()).optional(),
publicIds: z publicIds: z
.array( .array(
@@ -263,6 +374,13 @@ export type Event = z.infer<typeof EventSchema>;
export type Notice = z.infer<typeof NoticeSchema>; export type Notice = z.infer<typeof NoticeSchema>;
export type Remark = z.infer<typeof RemarkSchema>; export type Remark = z.infer<typeof RemarkSchema>;
export type VCardArray = z.infer<typeof VCardArraySchema>; export type VCardArray = z.infer<typeof VCardArraySchema>;
export type JSCard = z.infer<typeof JSCardSchema>;
export type JSContactName = z.infer<typeof JSContactNameSchema>;
export type JSContactEmail = z.infer<typeof JSContactEmailSchema>;
export type JSContactPhone = z.infer<typeof JSContactPhoneSchema>;
export type JSContactAddress = z.infer<typeof JSContactAddressSchema>;
export type JSContactOrganization = z.infer<typeof JSContactOrganizationSchema>;
export type JSContactTitle = z.infer<typeof JSContactTitleSchema>;
export type IPAddresses = z.infer<typeof IPAddressesSchema>; export type IPAddresses = z.infer<typeof IPAddressesSchema>;
export type DSData = z.infer<typeof DSDataSchema>; export type DSData = z.infer<typeof DSDataSchema>;
export type KeyData = z.infer<typeof KeyDataSchema>; export type KeyData = z.infer<typeof KeyDataSchema>;

View File

@@ -3,9 +3,18 @@ import { Result } from "true-myth";
/** /**
* Fetch and parse RDAP data from a URL * Fetch and parse RDAP data from a URL
* @param url - The URL to fetch
* @param schema - The Zod schema to validate the response
* @param followRedirects - Whether to automatically follow HTTP redirects (default: false)
*/ */
export async function getAndParse<T>(url: string, schema: ZodSchema<T>): Promise<Result<T, Error>> { export async function getAndParse<T>(
const response = await fetch(url); url: string,
schema: ZodSchema<T>,
followRedirects = false
): Promise<Result<T, Error>> {
const response = await fetch(url, {
redirect: followRedirects ? "follow" : "manual",
});
if (response.status === 200) { if (response.status === 200) {
const result = schema.safeParse(await response.json()); const result = schema.safeParse(await response.json());

View File

@@ -3,10 +3,19 @@ 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";
export interface URLQueryParams {
jsContact?: boolean;
followReferral?: boolean;
}
/** /**
* 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(type: RootRegistryType, lookupTarget: string): string { export function getRegistryURL(
type: RootRegistryType,
lookupTarget: string,
queryParams?: URLQueryParams
): 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.`);
@@ -82,5 +91,17 @@ export function getRegistryURL(type: RootRegistryType, lookupTarget: string): st
// ip4 and ip6 both use the 'ip' endpoint in RDAP // ip4 and ip6 both use the 'ip' endpoint in RDAP
const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type; const rdapPath = type === "ip4" || type === "ip6" ? "ip" : type;
return `${url}${rdapPath}/${lookupTarget}`; // Build query parameters string
const params = new URLSearchParams();
if (queryParams?.jsContact) {
params.append("jsContact", "1");
}
if (queryParams?.followReferral) {
params.append("followReferral", "1");
}
const queryString = params.toString();
const baseUrl = `${url}${rdapPath}/${lookupTarget}`;
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
} }