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:
2026-06-19 13:24:26 +02:00
parent 4867c30bcd
commit 2e0a4ddc21
6 changed files with 244 additions and 0 deletions
+2
View File
@@ -8,6 +8,7 @@ import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-passwo
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LdapModule } from './ldap/ldap.module'; import { LdapModule } from './ldap/ldap.module';
import { MailModule } from './mail/mail.module'; import { MailModule } from './mail/mail.module';
import { DomaincheckModule } from './domaincheck/domaincheck.module';
import { ModuleRegistryModule } from './module-registry/module-registry.module'; import { ModuleRegistryModule } from './module-registry/module-registry.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { TenantMiddleware } from './tenant/tenant.middleware'; import { TenantMiddleware } from './tenant/tenant.middleware';
@@ -25,6 +26,7 @@ import { UserModule } from './user/user.module';
MailModule, MailModule,
LdapModule, LdapModule,
ModuleRegistryModule, ModuleRegistryModule,
DomaincheckModule,
], ],
providers: [ providers: [
// Global JWT guard: all routes require auth unless @Public() // 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[];
}