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 { 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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user