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)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
try {
|
||||
await seedDomaincheckModule(this.moduleRegistryService);
|
||||
this.logger.log('Domaincheck module seeded in registry');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed domaincheck module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<DomainCheckResult[]> {
|
||||
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<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) =>
|
||||
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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user