From 2e0a4ddc212506cf7ed2a7b6910eab60a51588e2 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 19 Jun 2026 13:24:26 +0200 Subject: [PATCH] feat(03-02): add domaincheck backend - DNS service, API endpoint, module seed - CheckDomainDto with regex validation (T-03-05) and max 10 TLDs (T-03-06) - DomaincheckService using node:dns/promises with 5s timeout per lookup - POST /modules/domaincheck/check protected by UseModule guard (T-03-08) - DomaincheckModule seeds itself into registry on startup via OnModuleInit - Default TLDs: de, com, net, org (D-03) --- apps/api/src/app.module.ts | 2 + .../src/domaincheck/domaincheck.controller.ts | 38 +++++++ .../api/src/domaincheck/domaincheck.module.ts | 38 +++++++ apps/api/src/domaincheck/domaincheck.seed.ts | 25 ++++ .../src/domaincheck/domaincheck.service.ts | 107 ++++++++++++++++++ .../src/domaincheck/dto/check-domain.dto.ts | 34 ++++++ 6 files changed, 244 insertions(+) create mode 100644 apps/api/src/domaincheck/domaincheck.controller.ts create mode 100644 apps/api/src/domaincheck/domaincheck.module.ts create mode 100644 apps/api/src/domaincheck/domaincheck.seed.ts create mode 100644 apps/api/src/domaincheck/domaincheck.service.ts create mode 100644 apps/api/src/domaincheck/dto/check-domain.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5cd3159..6f61374 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo import { HealthModule } from './health/health.module'; import { LdapModule } from './ldap/ldap.module'; import { MailModule } from './mail/mail.module'; +import { DomaincheckModule } from './domaincheck/domaincheck.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { PrismaModule } from './prisma/prisma.module'; import { TenantMiddleware } from './tenant/tenant.middleware'; @@ -25,6 +26,7 @@ import { UserModule } from './user/user.module'; MailModule, LdapModule, ModuleRegistryModule, + DomaincheckModule, ], providers: [ // Global JWT guard: all routes require auth unless @Public() diff --git a/apps/api/src/domaincheck/domaincheck.controller.ts b/apps/api/src/domaincheck/domaincheck.controller.ts new file mode 100644 index 0000000..4cc69e5 --- /dev/null +++ b/apps/api/src/domaincheck/domaincheck.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Post } from '@nestjs/common'; +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, + ); + + return { + domain: dto.domain, + results, + }; + } +} diff --git a/apps/api/src/domaincheck/domaincheck.module.ts b/apps/api/src/domaincheck/domaincheck.module.ts new file mode 100644 index 0000000..d6e5ae0 --- /dev/null +++ b/apps/api/src/domaincheck/domaincheck.module.ts @@ -0,0 +1,38 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { ModuleRegistryModule } from '../module-registry/module-registry.module'; +import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import { DomaincheckController } from './domaincheck.controller'; +import { seedDomaincheckModule } from './domaincheck.seed'; +import { DomaincheckService } from './domaincheck.service'; + +/** + * NestJS module for the Domaincheck feature. + * + * Provides DNS-based domain availability checking across TLD variants. + * Seeds itself into the module registry on application startup via + * OnModuleInit lifecycle hook. + * + * Imports ModuleRegistryModule for guard access (UseModule decorator) + * and seed function. PrismaModule is global, no explicit import needed. + */ +@Module({ + imports: [ModuleRegistryModule], + controllers: [DomaincheckController], + providers: [DomaincheckService], +}) +export class DomaincheckModule implements OnModuleInit { + private readonly logger = new Logger(DomaincheckModule.name); + + constructor( + private readonly moduleRegistryService: ModuleRegistryService, + ) {} + + async onModuleInit(): Promise { + try { + await seedDomaincheckModule(this.moduleRegistryService); + this.logger.log('Domaincheck module seeded in registry'); + } catch (error) { + this.logger.error('Failed to seed domaincheck module', error); + } + } +} diff --git a/apps/api/src/domaincheck/domaincheck.seed.ts b/apps/api/src/domaincheck/domaincheck.seed.ts new file mode 100644 index 0000000..4efd541 --- /dev/null +++ b/apps/api/src/domaincheck/domaincheck.seed.ts @@ -0,0 +1,25 @@ +import { ModuleRegistryService } from '../module-registry/module-registry.service'; + +/** + * Seeds the domaincheck module into the module registry. + * + * Called during DomaincheckModule initialization to ensure the + * "domaincheck" module record exists in the database. + * + * Per D-09: category is 'domain-tools'. + */ +export async function seedDomaincheckModule( + moduleRegistryService: ModuleRegistryService, +): Promise { + await moduleRegistryService.seedModule({ + slug: 'domaincheck', + name: 'Domaincheck', + version: '1.0.0', + category: 'domain-tools', + description: { + de: 'Domain-Verfuegbarkeit pruefen', + en: 'Check domain availability', + }, + isSystem: true, + }); +} diff --git a/apps/api/src/domaincheck/domaincheck.service.ts b/apps/api/src/domaincheck/domaincheck.service.ts new file mode 100644 index 0000000..28427f0 --- /dev/null +++ b/apps/api/src/domaincheck/domaincheck.service.ts @@ -0,0 +1,107 @@ +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'; +} + +/** + * Default TLD list for domain checks (per D-03). + */ +const DEFAULT_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 { + const tldsToCheck = tlds?.length ? tlds : DEFAULT_TLDS; + + // Per T-03-06: Promise.allSettled prevents one lookup from blocking others + const results = await Promise.allSettled( + tldsToCheck.map((tld) => this.checkSingleDomain(domain, tld)), + ); + + 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) + return { + tld: tldsToCheck[index], + fqdn: `${domain}.${tldsToCheck[index]}`, + status: 'registered' as const, + }; + }); + } + + /** + * 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, + ): Promise { + 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((_, reject) => + setTimeout( + () => reject(new Error(`DNS timeout for ${fqdn}`)), + DNS_TIMEOUT_MS, + ), + ), + ]); + + // DNS resolved successfully — domain is registered + return { tld, fqdn, status: 'registered' }; + } catch (error: unknown) { + const code = + error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + + if (code === 'ENOTFOUND' || code === 'ENODATA') { + return { tld, fqdn, status: 'available' }; + } + + // Timeout or other network error — safe default is 'registered' + this.logger.warn(`DNS check for ${fqdn} failed: ${error}`); + return { tld, fqdn, status: 'registered' }; + } + } +} diff --git a/apps/api/src/domaincheck/dto/check-domain.dto.ts b/apps/api/src/domaincheck/dto/check-domain.dto.ts new file mode 100644 index 0000000..7b0a950 --- /dev/null +++ b/apps/api/src/domaincheck/dto/check-domain.dto.ts @@ -0,0 +1,34 @@ +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + Matches, +} from 'class-validator'; + +/** + * DTO for domain availability check requests. + * + * Per T-03-05: domain input is validated with regex — only alphanumeric + * characters and hyphens allowed, no dots, max 63 chars (DNS label limit). + */ +export class CheckDomainDto { + @IsString() + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, { + message: + 'Domain must be a valid label: alphanumeric and hyphens only, 1-63 characters, cannot start or end with a hyphen', + }) + 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[]; +}