Upsert ArvanCloud DNS records when a super-admin adds or updates a domain.
Tenant zones get an apex ANAME and CNAMEs for www, business, customer, and api without storing origin IPs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d08a85cee5
commit
30119e6d9e
@@ -95,6 +95,12 @@ WEBSITE_DEPLOY_TOKEN=
|
||||
SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync
|
||||
SSL_SYNC_AGENT_TOKEN=
|
||||
|
||||
# ArvanCloud CDN DNS (Machine User key from Arvan panel)
|
||||
# Header sent as: Authorization: Apikey <ARVAN_API_KEY>
|
||||
ARVAN_API_KEY=
|
||||
# Optional override; default https://napi.arvancloud.ir/cdn/4.0
|
||||
# ARVAN_API_BASE_URL=https://napi.arvancloud.ir/cdn/4.0
|
||||
|
||||
# Public domain for platform invoice links (https://{domain}/invoices/{id})
|
||||
INVOICE_PUBLIC_DOMAIN=meshkee.com
|
||||
# Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Meshkee CMS API — Project Context
|
||||
|
||||
> Living reference for developers and AI assistants working on this codebase.
|
||||
> Last updated: August 11, 2026
|
||||
> Last updated: August 14, 2026
|
||||
|
||||
## What This Project Is
|
||||
|
||||
@@ -597,6 +597,7 @@ See `.env.example` for the full list. Key groups:
|
||||
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
|
||||
| SMS | `SMS_ENABLED`, `SMS_GAMA_BASE_URL`, `SMS_GAMA_USERNAME`, `SMS_GAMA_PASSWORD`, `SMS_GAMA_SOURCE_SERVICE`, `SMS_PARTNERS` |
|
||||
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
||||
| ArvanCloud DNS | `ARVAN_API_KEY`, optional `ARVAN_API_BASE_URL` |
|
||||
| Legacy MySQL (WillaEngine migrate) | `OLD_MYSQL_HOST`, `OLD_MYSQL_PORT`, `OLD_MYSQL_USER`, `OLD_MYSQL_PASSWORD`, `OLD_MYSQL_DATABASE` |
|
||||
| Legacy S3 source (media copy) | `OLD_S3_ENDPOINT`, `OLD_S3_BUCKET`, `OLD_S3_PUBLIC_URL`, `OLD_S3_ACCESS_KEY_ID`, `OLD_S3_SECRET_ACCESS_KEY` |
|
||||
| Media | `MEDIA_MAX_FILE_SIZE_MB` |
|
||||
@@ -609,6 +610,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: 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)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ArvanDnsService } from './arvan-dns.service';
|
||||
|
||||
@Module({
|
||||
providers: [ArvanDnsService],
|
||||
exports: [ArvanDnsService],
|
||||
})
|
||||
export class ArvanDnsModule {}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
|
||||
const TTL = 120;
|
||||
|
||||
type ArvanRecordType = 'aname' | 'cname';
|
||||
|
||||
type DesiredRecord = {
|
||||
type: ArvanRecordType;
|
||||
name: string;
|
||||
value: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ArvanDnsRecord = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
ttl?: number;
|
||||
cloud?: boolean;
|
||||
value?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ArvanDnsService {
|
||||
private readonly logger = new Logger(ArvanDnsService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Ensure Meshkee tenant records on an existing Arvan zone.
|
||||
* Does not register the domain or delete unrelated records.
|
||||
*/
|
||||
async ensureTenantRecords(hostRaw: string): Promise<{
|
||||
created: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
}> {
|
||||
const host = hostRaw.trim().toLowerCase();
|
||||
if (!host) {
|
||||
throw new ServiceUnavailableException('Domain host is required for Arvan DNS');
|
||||
}
|
||||
|
||||
const auth = this.authHeader();
|
||||
if (!auth) {
|
||||
throw new ServiceUnavailableException('Arvan DNS is not configured');
|
||||
}
|
||||
|
||||
const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth);
|
||||
if (zone.status === 404) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan zone "${host}" was not found. Add the domain in Arvan first.`,
|
||||
);
|
||||
}
|
||||
if (!zone.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.listRecords(host, auth);
|
||||
const created: string[] = [];
|
||||
const updated: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const desired of this.desiredRecords(host)) {
|
||||
const match = existing.find(
|
||||
(item) => item.name === desired.name && item.type === desired.type,
|
||||
);
|
||||
const label = `${desired.type} ${desired.name}`;
|
||||
|
||||
if (match && this.sameTarget(desired, match)) {
|
||||
skipped.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const put = await this.request('PUT', `/domains/${encodeURIComponent(host)}/dns-records/${match.id}`, auth, {
|
||||
type: desired.type,
|
||||
name: desired.name,
|
||||
ttl: match.ttl || TTL,
|
||||
cloud: false,
|
||||
value: desired.value,
|
||||
});
|
||||
if (!put.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan failed to update ${label} (${put.status})${put.message ? `: ${put.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
updated.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sameNameOtherType = existing.find(
|
||||
(item) => item.name === desired.name && item.type !== desired.type && item.type !== 'ns',
|
||||
);
|
||||
if (sameNameOtherType) {
|
||||
const del = await this.request(
|
||||
'DELETE',
|
||||
`/domains/${encodeURIComponent(host)}/dns-records/${sameNameOtherType.id}`,
|
||||
auth,
|
||||
);
|
||||
if (!del.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan failed to replace ${sameNameOtherType.type} ${desired.name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
|
||||
type: desired.type,
|
||||
name: desired.name,
|
||||
ttl: TTL,
|
||||
cloud: false,
|
||||
value: desired.value,
|
||||
});
|
||||
if (!post.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
created.push(label);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Arvan DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`,
|
||||
);
|
||||
|
||||
return { created, updated, skipped };
|
||||
}
|
||||
|
||||
private desiredRecords(apex: string): DesiredRecord[] {
|
||||
return [
|
||||
{
|
||||
type: 'aname',
|
||||
name: '@',
|
||||
value: { location: 'ns.meshkee.com.', host_header: 'source' },
|
||||
},
|
||||
{
|
||||
type: 'cname',
|
||||
name: 'www',
|
||||
value: { host: `${apex}.`, host_header: 'source' },
|
||||
},
|
||||
{
|
||||
type: 'cname',
|
||||
name: 'business',
|
||||
value: { host: 'business.meshkee.com.', host_header: 'source' },
|
||||
},
|
||||
{
|
||||
type: 'cname',
|
||||
name: 'customer',
|
||||
value: { host: 'customer.meshkee.com.', host_header: 'source' },
|
||||
},
|
||||
{
|
||||
type: 'cname',
|
||||
name: 'api',
|
||||
value: { host: 'api.meshkee.com.', host_header: 'source' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
||||
const current = existing.value ?? {};
|
||||
if (desired.type === 'aname') {
|
||||
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
|
||||
}
|
||||
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
|
||||
}
|
||||
|
||||
private fqdn(value: string): string {
|
||||
return value.trim().replace(/\.$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
private async listRecords(domain: string, auth: string): Promise<ArvanDnsRecord[]> {
|
||||
const items: ArvanDnsRecord[] = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const res = await this.request(
|
||||
'GET',
|
||||
`/domains/${encodeURIComponent(domain)}/dns-records?per_page=50&page=${page}`,
|
||||
auth,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Arvan could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
const payload = (res.body ?? {}) as {
|
||||
data?: ArvanDnsRecord[];
|
||||
meta?: { last_page?: number };
|
||||
};
|
||||
const data = Array.isArray(payload.data) ? payload.data : [];
|
||||
items.push(...data);
|
||||
const lastPage = payload.meta?.last_page ?? 1;
|
||||
if (page >= lastPage) break;
|
||||
page += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private authHeader(): string | null {
|
||||
const raw = this.config.get<string>('ARVAN_API_KEY')?.trim();
|
||||
if (!raw) return null;
|
||||
return raw.toLowerCase().startsWith('apikey ') ? raw : `Apikey ${raw}`;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
return (
|
||||
this.config.get<string>('ARVAN_API_BASE_URL')?.trim().replace(/\/$/, '') || DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: string,
|
||||
path: string,
|
||||
auth: string,
|
||||
body?: unknown,
|
||||
): Promise<{ ok: boolean; status: number; body: Record<string, unknown> | null; message: string }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl()}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach ArvanCloud DNS API');
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
let parsed: Record<string, unknown> = {};
|
||||
try {
|
||||
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
|
||||
const message =
|
||||
(typeof parsed.message === 'string' && parsed.message) ||
|
||||
(text && !response.ok ? text.slice(0, 300) : '');
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
|
||||
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||
import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
@@ -9,7 +10,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
||||
import { LegacyPurgeService } from './legacy-purge.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, WebsiteDeployModule],
|
||||
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
|
||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||
providers: [
|
||||
BusinessAdminService,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
DEFAULT_HOME_CHARTS,
|
||||
} from '../business-settings/business-settings.types';
|
||||
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
|
||||
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||
import {
|
||||
deploySlugFromHost,
|
||||
@@ -88,6 +90,7 @@ export class BusinessAdminService {
|
||||
private readonly legacyMigrate: LegacyMigrateService,
|
||||
private readonly legacyPurge: LegacyPurgeService,
|
||||
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||
private readonly arvanDns: ArvanDnsService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
@@ -545,9 +548,12 @@ export class BusinessAdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const dnsError = await this.applyArvanDns(host);
|
||||
|
||||
return {
|
||||
...domain,
|
||||
provisionError,
|
||||
dnsError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -642,12 +648,35 @@ export class BusinessAdminService {
|
||||
},
|
||||
});
|
||||
|
||||
const dnsError = await this.applyArvanDns(host);
|
||||
|
||||
return {
|
||||
...updated,
|
||||
provisionError,
|
||||
dnsError,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyArvanDns(host: string): Promise<string | null> {
|
||||
try {
|
||||
await this.arvanDns.ensureTenantRecords(host);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) {
|
||||
const res = err.getResponse();
|
||||
if (typeof res === 'string') return res;
|
||||
if (res && typeof res === 'object' && 'message' in res) {
|
||||
const message = (res as { message: unknown }).message;
|
||||
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
|
||||
}
|
||||
}
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
return err.message;
|
||||
}
|
||||
return 'Arvan DNS update failed';
|
||||
}
|
||||
}
|
||||
|
||||
async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user