diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index ffab8e7..60b5b93 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -642,7 +642,7 @@ See `.env.example` for the full list. Key groups: - Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Super admin: users, businesses, domains, system business categories -- Super admin add/update domain upserts ArvanCloud DNS (`@` ANAME, `www`/`business`/`customer`/`api` CNAMEs) +- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only) - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Business team management - Media upload (S3 + Sharp) diff --git a/src/business-admin/business-admin.module.ts b/src/business-admin/business-admin.module.ts index 007629c..f1580be 100644 --- a/src/business-admin/business-admin.module.ts +++ b/src/business-admin/business-admin.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module'; +import { CloudflareDnsModule } from '../cloudflare-dns/cloudflare-dns.module'; import { WebsiteDeployModule } from '../website-deploy/website-deploy.module'; import { BusinessCategoriesController } from './business-categories.controller'; import { BusinessCategoriesService } from './business-categories.service'; @@ -10,7 +11,7 @@ import { LegacyMigrateService } from './legacy-migrate.service'; import { LegacyPurgeService } from './legacy-purge.service'; @Module({ - imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule], + imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule], controllers: [BusinessAdminController, BusinessCategoriesController], providers: [ BusinessAdminService, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 2b6290e..002427f 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -45,6 +45,7 @@ import { DEFAULT_NEW_BUSINESS_MODULES, } from '../business-settings/business-settings.types'; import { ArvanDnsService } from '../arvan-dns/arvan-dns.service'; +import { CloudflareDnsService } from '../cloudflare-dns/cloudflare-dns.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; import { deploySlugFromHost, @@ -95,6 +96,7 @@ export class BusinessAdminService { private readonly legacyPurge: LegacyPurgeService, private readonly websiteDeployAgent: WebsiteDeployAgentService, private readonly arvanDns: ArvanDnsService, + private readonly cloudflareDns: CloudflareDnsService, ) {} private async assertSuperAdmin(actor: AuthUser) { @@ -571,7 +573,9 @@ export class BusinessAdminService { }, }); - const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null; + const dnsError = dto.updateDns + ? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan') + : null; return { ...domain, @@ -675,7 +679,9 @@ export class BusinessAdminService { }, }); - const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null; + const dnsError = dto.updateDns + ? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan') + : null; return { ...updated, @@ -684,9 +690,16 @@ export class BusinessAdminService { }; } - private async applyArvanDns(host: string): Promise { + private async applyTenantDns( + host: string, + provider: 'arvan' | 'cloudflare', + ): Promise { try { - await this.arvanDns.ensureTenantRecords(host); + if (provider === 'cloudflare') { + await this.cloudflareDns.ensureTenantRecords(host); + } else { + await this.arvanDns.ensureTenantRecords(host); + } return null; } catch (err) { if (err instanceof HttpException) { @@ -700,7 +713,7 @@ export class BusinessAdminService { if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { return err.message; } - return 'Arvan DNS update failed'; + return provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed'; } } diff --git a/src/business-admin/dto/add-domain.dto.ts b/src/business-admin/dto/add-domain.dto.ts index f18d0a8..76c4c42 100644 --- a/src/business-admin/dto/add-domain.dto.ts +++ b/src/business-admin/dto/add-domain.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util'; export class AddDomainDto { @@ -10,11 +10,16 @@ export class AddDomainDto { @IsBoolean() isPrimary?: boolean; - /** When true, upsert Meshkee tenant records in Arvan DNS. */ + /** When true, upsert Meshkee tenant records (@, www, business, customer, api). */ @IsOptional() @IsBoolean() updateDns?: boolean; + /** DNS host for tenant records. Ignored unless updateDns is true. Default arvan. */ + @IsOptional() + @IsIn(['arvan', 'cloudflare']) + dnsProvider?: 'arvan' | 'cloudflare'; + /** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */ @IsOptional() @IsString() diff --git a/src/business-admin/dto/update-domain.dto.ts b/src/business-admin/dto/update-domain.dto.ts index ebcabd6..fa9f7fe 100644 --- a/src/business-admin/dto/update-domain.dto.ts +++ b/src/business-admin/dto/update-domain.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator'; +import { IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util'; export class UpdateDomainDto { @@ -6,11 +6,16 @@ export class UpdateDomainDto { @MinLength(1) host!: string; - /** When true, upsert Meshkee tenant records in Arvan DNS. */ + /** When true, upsert Meshkee tenant records (@, www, business, customer, api). */ @IsOptional() @IsBoolean() updateDns?: boolean; + /** DNS host for tenant records. Ignored unless updateDns is true. Default arvan. */ + @IsOptional() + @IsIn(['arvan', 'cloudflare']) + dnsProvider?: 'arvan' | 'cloudflare'; + /** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */ @IsOptional() @IsString() diff --git a/src/cloudflare-dns/cloudflare-dns.service.ts b/src/cloudflare-dns/cloudflare-dns.service.ts index 2383650..9d82318 100644 --- a/src/cloudflare-dns/cloudflare-dns.service.ts +++ b/src/cloudflare-dns/cloudflare-dns.service.ts @@ -28,6 +28,37 @@ export class CloudflareDnsService { created: string[]; updated: string[]; skipped: string[]; + }> { + return this.ensureCnameRecords(hostRaw, (host) => [ + { relative: '@', content: TARGET }, + { relative: 'www', content: host }, + ]); + } + + /** + * Full Meshkee tenant: @ + www + business + customer + api, DNS-only. + */ + async ensureTenantRecords(hostRaw: string): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; + }> { + return this.ensureCnameRecords(hostRaw, (host) => [ + { relative: '@', content: TARGET }, + { relative: 'www', content: host }, + { relative: 'business', content: 'business.meshkee.com' }, + { relative: 'customer', content: 'customer.meshkee.com' }, + { relative: 'api', content: 'api.meshkee.com' }, + ]); + } + + private async ensureCnameRecords( + hostRaw: string, + desiredFor: (apex: string) => Array<{ relative: string; content: string }>, + ): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; }> { const host = hostRaw.trim().toLowerCase(); if (!host) { @@ -47,15 +78,10 @@ export class CloudflareDnsService { const updated: string[] = []; const skipped: string[] = []; - const desired: Array<{ type: 'CNAME'; relative: string; content: string }> = [ - { type: 'CNAME', relative: '@', content: TARGET }, - { type: 'CNAME', relative: 'www', content: host }, - ]; - - for (const item of desired) { - const label = `${item.type} ${item.relative}`; + for (const item of desiredFor(host)) { + const label = `CNAME ${item.relative}`; const match = existing.find( - (rec) => rec.type === item.type && this.relativeName(rec.name, host) === item.relative, + (rec) => rec.type === 'CNAME' && this.relativeName(rec.name, host) === item.relative, ); if (match && this.sameCname(match, item.content) && match.proxied === false) { @@ -63,10 +89,7 @@ export class CloudflareDnsService { continue; } - // Apex CNAME cannot coexist with A/AAAA. - if (item.relative === '@') { - await this.deleteConflictingApexAddress(zoneId, token, existing, host); - } + await this.deleteConflictingAddress(zoneId, token, existing, host, item.relative); if (match) { const patch = await this.request( @@ -74,7 +97,7 @@ export class CloudflareDnsService { `/zones/${zoneId}/dns_records/${match.id}`, token, { - type: item.type, + type: 'CNAME', name: item.relative, content: item.content, ttl: TTL, @@ -91,7 +114,7 @@ export class CloudflareDnsService { } const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, { - type: item.type, + type: 'CNAME', name: item.relative, content: item.content, ttl: TTL, @@ -163,21 +186,23 @@ export class CloudflareDnsService { return items; } - private async deleteConflictingApexAddress( + private async deleteConflictingAddress( zoneId: string, token: string, existing: CfRecord[], zone: string, + relative: string, ) { const conflicts = existing.filter( (rec) => - (rec.type === 'A' || rec.type === 'AAAA') && this.relativeName(rec.name, zone) === '@', + (rec.type === 'A' || rec.type === 'AAAA') && + this.relativeName(rec.name, zone) === relative, ); for (const rec of conflicts) { const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token); if (!del.ok) { throw new ServiceUnavailableException( - `Cloudflare failed to replace ${rec.type} @ (${del.status})${del.message ? `: ${del.message}` : ''}`, + `Cloudflare failed to replace ${rec.type} ${relative} (${del.status})${del.message ? `: ${del.message}` : ''}`, ); } } diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index 9ab730a..853d990 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -719,8 +719,10 @@ export class DomainAdminService { }, }); - // Same Arvan helper as business-admin domain add/edit (`ensureTenantRecords`). - const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null; + // Same DNS helper as business-admin domain add/edit (`ensureTenantRecords`). + const dnsError = dto.updateDns + ? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan') + : null; return { ...updated, @@ -936,12 +938,22 @@ export class DomainAdminService { } } - private async applyArvanDns(host: string): Promise { + private async applyTenantDns( + host: string, + provider: ParkedDnsProvider, + ): Promise { try { - await this.arvanDns.ensureTenantRecords(host); + if (provider === 'cloudflare') { + await this.cloudflareDns.ensureTenantRecords(host); + } else { + await this.arvanDns.ensureTenantRecords(host); + } return null; } catch (err) { - return this.httpErrorMessage(err, 'Arvan DNS update failed'); + return this.httpErrorMessage( + err, + provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed', + ); } } diff --git a/src/domain-admin/dto/update-domain-admin.dto.ts b/src/domain-admin/dto/update-domain-admin.dto.ts index 90299a0..b67ddc8 100644 --- a/src/domain-admin/dto/update-domain-admin.dto.ts +++ b/src/domain-admin/dto/update-domain-admin.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsDateString, IsOptional, IsString, Matches, MinLength } from 'class-validator'; +import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util'; export class UpdateDomainAdminDto { @@ -11,11 +11,16 @@ export class UpdateDomainAdminDto { @IsDateString() expiresAt?: string; - /** When true, upsert Meshkee tenant records in Arvan DNS. */ + /** When true, upsert Meshkee tenant records (@, www, business, customer, api). */ @IsOptional() @IsBoolean() updateDns?: boolean; + /** DNS host for tenant records. Ignored unless updateDns is true. Default arvan. */ + @IsOptional() + @IsIn(['arvan', 'cloudflare']) + dnsProvider?: 'arvan' | 'cloudflare'; + /** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */ @IsOptional() @IsString()