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)
|
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- 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)
|
- 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
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.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 { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||||
import { BusinessCategoriesController } from './business-categories.controller';
|
import { BusinessCategoriesController } from './business-categories.controller';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
@@ -10,7 +11,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
|||||||
import { LegacyPurgeService } from './legacy-purge.service';
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
|
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
|
||||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||||
providers: [
|
providers: [
|
||||||
BusinessAdminService,
|
BusinessAdminService,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
DEFAULT_NEW_BUSINESS_MODULES,
|
DEFAULT_NEW_BUSINESS_MODULES,
|
||||||
} from '../business-settings/business-settings.types';
|
} from '../business-settings/business-settings.types';
|
||||||
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
|
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 { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||||
import {
|
import {
|
||||||
deploySlugFromHost,
|
deploySlugFromHost,
|
||||||
@@ -95,6 +96,7 @@ export class BusinessAdminService {
|
|||||||
private readonly legacyPurge: LegacyPurgeService,
|
private readonly legacyPurge: LegacyPurgeService,
|
||||||
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||||
private readonly arvanDns: ArvanDnsService,
|
private readonly arvanDns: ArvanDnsService,
|
||||||
|
private readonly cloudflareDns: CloudflareDnsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertSuperAdmin(actor: AuthUser) {
|
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 {
|
return {
|
||||||
...domain,
|
...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 {
|
return {
|
||||||
...updated,
|
...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 {
|
try {
|
||||||
await this.arvanDns.ensureTenantRecords(host);
|
if (provider === 'cloudflare') {
|
||||||
|
await this.cloudflareDns.ensureTenantRecords(host);
|
||||||
|
} else {
|
||||||
|
await this.arvanDns.ensureTenantRecords(host);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) {
|
if (err instanceof HttpException) {
|
||||||
@@ -700,7 +713,7 @@ export class BusinessAdminService {
|
|||||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||||
return err.message;
|
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';
|
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
|
||||||
|
|
||||||
export class AddDomainDto {
|
export class AddDomainDto {
|
||||||
@@ -10,11 +10,16 @@ export class AddDomainDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
|
||||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
updateDns?: boolean;
|
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. */
|
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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';
|
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
|
||||||
|
|
||||||
export class UpdateDomainDto {
|
export class UpdateDomainDto {
|
||||||
@@ -6,11 +6,16 @@ export class UpdateDomainDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
host!: string;
|
host!: string;
|
||||||
|
|
||||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
updateDns?: boolean;
|
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. */
|
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -28,6 +28,37 @@ export class CloudflareDnsService {
|
|||||||
created: string[];
|
created: string[];
|
||||||
updated: string[];
|
updated: string[];
|
||||||
skipped: 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();
|
const host = hostRaw.trim().toLowerCase();
|
||||||
if (!host) {
|
if (!host) {
|
||||||
@@ -47,15 +78,10 @@ export class CloudflareDnsService {
|
|||||||
const updated: string[] = [];
|
const updated: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
|
|
||||||
const desired: Array<{ type: 'CNAME'; relative: string; content: string }> = [
|
for (const item of desiredFor(host)) {
|
||||||
{ type: 'CNAME', relative: '@', content: TARGET },
|
const label = `CNAME ${item.relative}`;
|
||||||
{ type: 'CNAME', relative: 'www', content: host },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const item of desired) {
|
|
||||||
const label = `${item.type} ${item.relative}`;
|
|
||||||
const match = existing.find(
|
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) {
|
if (match && this.sameCname(match, item.content) && match.proxied === false) {
|
||||||
@@ -63,10 +89,7 @@ export class CloudflareDnsService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apex CNAME cannot coexist with A/AAAA.
|
await this.deleteConflictingAddress(zoneId, token, existing, host, item.relative);
|
||||||
if (item.relative === '@') {
|
|
||||||
await this.deleteConflictingApexAddress(zoneId, token, existing, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
const patch = await this.request(
|
const patch = await this.request(
|
||||||
@@ -74,7 +97,7 @@ export class CloudflareDnsService {
|
|||||||
`/zones/${zoneId}/dns_records/${match.id}`,
|
`/zones/${zoneId}/dns_records/${match.id}`,
|
||||||
token,
|
token,
|
||||||
{
|
{
|
||||||
type: item.type,
|
type: 'CNAME',
|
||||||
name: item.relative,
|
name: item.relative,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
ttl: TTL,
|
ttl: TTL,
|
||||||
@@ -91,7 +114,7 @@ export class CloudflareDnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
|
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
|
||||||
type: item.type,
|
type: 'CNAME',
|
||||||
name: item.relative,
|
name: item.relative,
|
||||||
content: item.content,
|
content: item.content,
|
||||||
ttl: TTL,
|
ttl: TTL,
|
||||||
@@ -163,21 +186,23 @@ export class CloudflareDnsService {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteConflictingApexAddress(
|
private async deleteConflictingAddress(
|
||||||
zoneId: string,
|
zoneId: string,
|
||||||
token: string,
|
token: string,
|
||||||
existing: CfRecord[],
|
existing: CfRecord[],
|
||||||
zone: string,
|
zone: string,
|
||||||
|
relative: string,
|
||||||
) {
|
) {
|
||||||
const conflicts = existing.filter(
|
const conflicts = existing.filter(
|
||||||
(rec) =>
|
(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) {
|
for (const rec of conflicts) {
|
||||||
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
|
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
|
||||||
if (!del.ok) {
|
if (!del.ok) {
|
||||||
throw new ServiceUnavailableException(
|
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`).
|
// Same DNS helper as business-admin domain add/edit (`ensureTenantRecords`).
|
||||||
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
|
const dnsError = dto.updateDns
|
||||||
|
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...updated,
|
...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 {
|
try {
|
||||||
await this.arvanDns.ensureTenantRecords(host);
|
if (provider === 'cloudflare') {
|
||||||
|
await this.cloudflareDns.ensureTenantRecords(host);
|
||||||
|
} else {
|
||||||
|
await this.arvanDns.ensureTenantRecords(host);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} 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';
|
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
|
||||||
|
|
||||||
export class UpdateDomainAdminDto {
|
export class UpdateDomainAdminDto {
|
||||||
@@ -11,11 +11,16 @@ export class UpdateDomainAdminDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
|
||||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
updateDns?: boolean;
|
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. */
|
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user