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:
co-authored by
Cursor
parent
d1f60b5313
commit
f42e6fe2a4
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user