feat(domaincheck): support all TLDs with suggestion alternatives
- Accept full domains (e.g. "example.xyz") not just labels - Check the entered TLD as primary result - Show .de, .com, .net, .org as alternative suggestions below - Primary result highlighted with accent border - Input without TLD still works (shows all 4 suggestions) - Updated i18n placeholders and added "suggestions" label Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,32 +3,14 @@ import { UseModule } from '../module-registry/module.guard';
|
||||
import { CheckDomainDto } from './dto/check-domain.dto';
|
||||
import { DomaincheckService } from './domaincheck.service';
|
||||
|
||||
/**
|
||||
* Controller for the Domaincheck module.
|
||||
*
|
||||
* POST /modules/domaincheck/check — accepts a domain label, checks TLD
|
||||
* variants via DNS, and returns availability status for each.
|
||||
*
|
||||
* Protected by @UseModule('domaincheck') which ensures the requesting
|
||||
* tenant has the domaincheck module activated (per T-03-08).
|
||||
*/
|
||||
@Controller('modules/domaincheck')
|
||||
@UseModule('domaincheck')
|
||||
export class DomaincheckController {
|
||||
constructor(private readonly domaincheckService: DomaincheckService) {}
|
||||
|
||||
/**
|
||||
* Check domain availability across TLD variants.
|
||||
*
|
||||
* @param dto - Contains domain label and optional TLD list
|
||||
* @returns Domain and results array with tld, fqdn, and status
|
||||
*/
|
||||
@Post('check')
|
||||
async checkDomain(@Body() dto: CheckDomainDto) {
|
||||
const results = await this.domaincheckService.checkDomain(
|
||||
dto.domain,
|
||||
dto.tlds,
|
||||
);
|
||||
const results = await this.domaincheckService.checkDomain(dto.domain);
|
||||
|
||||
return {
|
||||
domain: dto.domain,
|
||||
|
||||
@@ -1,82 +1,69 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as dns from 'node:dns/promises';
|
||||
|
||||
/**
|
||||
* Status of a domain+TLD availability check.
|
||||
*/
|
||||
export interface DomainCheckResult {
|
||||
tld: string;
|
||||
fqdn: string;
|
||||
status: 'available' | 'registered';
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TLD list for domain checks (per D-03).
|
||||
*/
|
||||
const DEFAULT_TLDS = ['de', 'com', 'net', 'org'];
|
||||
const SUGGESTION_TLDS = ['de', 'com', 'net', 'org'];
|
||||
|
||||
/**
|
||||
* Per T-03-06: timeout per individual DNS lookup (milliseconds).
|
||||
*/
|
||||
const DNS_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Service that checks domain availability across TLD variants via DNS.
|
||||
*
|
||||
* Per D-02: the system checks all TLD variants automatically from a base
|
||||
* domain name. Uses dns.resolve from node:dns/promises for each variant.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DomaincheckService {
|
||||
private readonly logger = new Logger(DomaincheckService.name);
|
||||
|
||||
/**
|
||||
* Check availability of a domain across multiple TLDs.
|
||||
*
|
||||
* @param domain - Base domain label (e.g., "example")
|
||||
* @param tlds - Optional TLD list; defaults to DEFAULT_TLDS
|
||||
* @returns Array of results with tld, fqdn, and availability status
|
||||
*/
|
||||
async checkDomain(
|
||||
domain: string,
|
||||
tlds?: string[],
|
||||
): Promise<DomainCheckResult[]> {
|
||||
const tldsToCheck = tlds?.length ? tlds : DEFAULT_TLDS;
|
||||
async checkDomain(input: string): Promise<DomainCheckResult[]> {
|
||||
const dotIndex = input.indexOf('.');
|
||||
let label: string;
|
||||
let primaryTld: string | null = null;
|
||||
|
||||
// Per T-03-06: Promise.allSettled prevents one lookup from blocking others
|
||||
const results = await Promise.allSettled(
|
||||
tldsToCheck.map((tld) => this.checkSingleDomain(domain, tld)),
|
||||
);
|
||||
if (dotIndex > 0) {
|
||||
label = input.substring(0, dotIndex);
|
||||
primaryTld = input.substring(dotIndex + 1).toLowerCase();
|
||||
} else {
|
||||
label = input;
|
||||
}
|
||||
|
||||
const suggestionTlds = SUGGESTION_TLDS.filter((t) => t !== primaryTld);
|
||||
|
||||
const allChecks: Promise<DomainCheckResult>[] = [];
|
||||
|
||||
if (primaryTld) {
|
||||
allChecks.push(this.checkSingleDomain(label, primaryTld, true));
|
||||
}
|
||||
|
||||
for (const tld of suggestionTlds) {
|
||||
allChecks.push(this.checkSingleDomain(label, tld, false));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(allChecks);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
// If Promise.allSettled rejects (shouldn't happen with our try/catch),
|
||||
// treat as registered (safe default per plan)
|
||||
const tld = index === 0 && primaryTld ? primaryTld : suggestionTlds[primaryTld ? index - 1 : index];
|
||||
return {
|
||||
tld: tldsToCheck[index],
|
||||
fqdn: `${domain}.${tldsToCheck[index]}`,
|
||||
tld,
|
||||
fqdn: `${label}.${tld}`,
|
||||
status: 'registered' as const,
|
||||
primary: index === 0 && !!primaryTld,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single domain+TLD combination via DNS resolution.
|
||||
*
|
||||
* - If resolves (any record) -> 'registered'
|
||||
* - If ENOTFOUND or ENODATA -> 'available'
|
||||
* - Any other error -> 'registered' (safe default)
|
||||
*/
|
||||
private async checkSingleDomain(
|
||||
domain: string,
|
||||
tld: string,
|
||||
primary: boolean,
|
||||
): Promise<DomainCheckResult> {
|
||||
const fqdn = `${domain}.${tld}`;
|
||||
|
||||
try {
|
||||
// Per T-03-06: 5-second timeout per lookup to prevent hanging
|
||||
await Promise.race([
|
||||
dns.resolve(fqdn),
|
||||
new Promise<never>((_, reject) =>
|
||||
@@ -87,8 +74,7 @@ export class DomaincheckService {
|
||||
),
|
||||
]);
|
||||
|
||||
// DNS resolved successfully — domain is registered
|
||||
return { tld, fqdn, status: 'registered' };
|
||||
return { tld, fqdn, status: 'registered', primary };
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error instanceof Error && 'code' in error
|
||||
@@ -96,12 +82,11 @@ export class DomaincheckService {
|
||||
: undefined;
|
||||
|
||||
if (code === 'ENOTFOUND' || code === 'ENODATA') {
|
||||
return { tld, fqdn, status: 'available' };
|
||||
return { tld, fqdn, status: 'available', primary };
|
||||
}
|
||||
|
||||
// Timeout or other network error — safe default is 'registered'
|
||||
this.logger.warn(`DNS check for ${fqdn} failed: ${error}`);
|
||||
return { tld, fqdn, status: 'registered' };
|
||||
return { tld, fqdn, status: 'registered', primary };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
@@ -16,19 +13,9 @@ import {
|
||||
export class CheckDomainDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, {
|
||||
@Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})?$/, {
|
||||
message:
|
||||
'Domain must be a valid label: alphanumeric and hyphens only, 1-63 characters, cannot start or end with a hyphen',
|
||||
'Domain must be a valid label (e.g. "example") or full domain (e.g. "example.com")',
|
||||
})
|
||||
domain!: string;
|
||||
|
||||
/**
|
||||
* Optional list of TLDs to check. If omitted, defaults are used.
|
||||
* Per T-03-06: max 10 TLDs per request to prevent DoS.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(10, { message: 'Maximum 10 TLDs per request' })
|
||||
tlds?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user