Allow add/edit domain to upsert tenant DNS on Cloudflare or Arvan.

Update DNS writes @, www, business, customer, and api records on the chosen provider instead of Arvan only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 18:46:48 +03:30
co-authored by Cursor
parent d1f60b5313
commit f42e6fe2a4
8 changed files with 101 additions and 35 deletions
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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,
+18 -5
View File
@@ -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<string | null> {
private async applyTenantDns(
host: string,
provider: 'arvan' | 'cloudflare',
): Promise<string | null> {
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';
}
}
+7 -2
View File
@@ -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()
+7 -2
View File
@@ -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()
+42 -17
View File
@@ -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}` : ''}`,
);
}
}
+17 -5
View File
@@ -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<string | null> {
private async applyTenantDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
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',
);
}
}
@@ -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()