feat(domaincheck): support all TLDs with suggestion alternatives
Tessera CI/CD / Lint & Type Check (push) Successful in 1m8s
Tessera CI/CD / Tests (push) Successful in 1m13s
Tessera CI/CD / Build & Deploy (push) Successful in 2m39s

- 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:
2026-06-25 14:54:30 +02:00
parent 95423497ad
commit 9f78580606
8 changed files with 95 additions and 134 deletions
@@ -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,
+34 -49
View File
@@ -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[];
}