Files
backend/src/domain-admin/domain-admin.service.ts
T
Alireza HassaniandCursor f42e6fe2a4 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>
2026-08-31 18:46:48 +03:30

1022 lines
32 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { CloudflareDnsService } from '../cloudflare-dns/cloudflare-dns.service';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
earliestTlsExpiry,
probeTlsHost,
probeTlsHostDetailed,
} from '../common/tls-probe';
import { PrismaService } from '../prisma/prisma.service';
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
import {
deploySlugFromHost,
isValidGitRepoUrl,
normalizeGitRepoUrlForClone,
} from '../website-deploy/website-deploy.util';
import { DisableDomainDto } from './dto/disable-domain.dto';
import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { isValidParkedHost, parseParkedDnsMap, uniqueParkedAliases, uniqueParkedHosts } from './parked-host.util';
import type { ParkedDnsProvider } from './parked-host.util';
type DomainRow = {
id: bigint;
host: string;
businessId: bigint;
businessName: string;
sslEnabled: boolean;
sslExpiresAt: Date | null;
isActive: boolean;
expiresAt: Date | null;
createdAt: Date;
lastDeployedAt: Date | null;
lastDeployStatus: string | null;
deploySlug: string | null;
gitRepoUrl: string | null;
parkedHosts: string[];
parkedDns: unknown;
};
@Injectable()
export class DomainAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
private readonly cloudflareDns: CloudflareDnsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
async list(query: ListDomainsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 24;
const skip = (page - 1) * pageSize;
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
const where = Prisma.sql`
WHERE 1=1
${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty}
`;
const [rows, totalRow] = await Promise.all([
this.prisma.$queryRaw<DomainRow[]>(Prisma.sql`
SELECT
d.id AS "id",
d.host AS "host",
d.business_id AS "businessId",
b.name AS "businessName",
d.ssl_enabled AS "sslEnabled",
d.ssl_expires_at AS "sslExpiresAt",
d.is_active AS "isActive",
d.expires_at AS "expiresAt",
d.created_at AS "createdAt",
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus",
d.deploy_slug AS "deploySlug",
d.git_repo_url AS "gitRepoUrl",
COALESCE(d.parked_hosts, '{}') AS "parkedHosts",
COALESCE(d.parked_dns, '{}'::jsonb) AS "parkedDns"
FROM domains d
JOIN businesses b ON b.id = d.business_id
${where}
ORDER BY d.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM domains d
${where}
`),
]);
const now = Date.now();
const items = rows.map((row) => {
const expiryMs = row.sslExpiresAt?.getTime() ?? null;
const certStillValid = expiryMs == null || expiryMs > now;
// Prefer stored notAfter when present (daily probe is only a writer).
const sslEnabled =
expiryMs != null ? expiryMs > now : row.sslEnabled && certStillValid;
return {
...row,
sslEnabled,
sslExpiresAt: row.sslExpiresAt,
parkedHosts: Array.isArray(row.parkedHosts) ? row.parkedHosts : [],
parkedAliases: (Array.isArray(row.parkedHosts) ? row.parkedHosts : []).map((host) => ({
host,
dns: parseParkedDnsMap(row.parkedDns)[host] ?? ('arvan' as const),
})),
};
});
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
}
async syncSsl(actor: AuthUser) {
await this.assertSuperAdmin(actor);
await this.callDashboardSslSync({ wait: false });
return {
status: 'accepted' as const,
message: 'Dashboard SSL sync started (manage / business.* / customer.*)',
};
}
/** Fire-and-forget or blocking call to the dashboards VPS ssl-sync agent. */
private async callDashboardSslSync(opts: { wait: boolean; tenantApex?: string }) {
const agentUrl = this.config.get<string>('SSL_SYNC_AGENT_URL')?.trim();
const token = this.config.get<string>('SSL_SYNC_AGENT_TOKEN')?.trim();
if (!agentUrl || !token) {
throw new ServiceUnavailableException('SSL sync agent is not configured');
}
const tenantApex = opts.tenantApex?.trim().toLowerCase();
let response: Response;
try {
response = await fetch(agentUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-SSL-Sync-Agent-Token': token,
},
body: JSON.stringify({
wait: opts.wait,
...(tenantApex ? { tenantApex } : {}),
}),
});
} catch {
throw new ServiceUnavailableException('Could not reach SSL sync agent');
}
if (response.status === 409) {
throw new ConflictException('SSL sync is already running');
}
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new ServiceUnavailableException(
`SSL sync agent rejected request (${response.status})${text ? `: ${this.summarizeSslSyncFailure(text)}` : ''}`,
);
}
return response.json().catch(() => ({ status: opts.wait ? 'ok' : 'accepted' }));
}
private summarizeSslSyncFailure(raw: string): string {
try {
const parsed = JSON.parse(raw) as { message?: string; log?: string };
const log = String(parsed.log ?? '');
const blocked = [
...new Set(
[...log.matchAll(/Domain: ((?:business|customer)\.[^\n]+)/g)].map((m) => m[1].trim()),
),
];
if (blocked.length > 0) {
return (
`Dashboard cert blocked by missing DNS for: ${blocked.join(', ')}. ` +
'Add business/customer CNAMEs for those tenants or deactivate the domain, then retry.'
);
}
return String(parsed.message ?? raw).slice(0, 400);
} catch {
return raw.slice(0, 400);
}
}
/**
* Issue Let's Encrypt certs on the websites VM for storefront domains
* whose live TLS probe fails. Do not trust ssl_enabled alone — a wrong
* default-vhost cert can leave the flag true while browsers show
* NET::ERR_CERT_COMMON_NAME_INVALID.
*/
async issueWebsiteSsl(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const candidates = await this.prisma.domain.findMany({
where: {
isActive: true,
deploySlug: { not: null },
},
select: { id: true, host: true, deploySlug: true, sslEnabled: true },
orderBy: { host: 'asc' },
});
const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = [];
for (const domain of candidates) {
const apexProbe = await probeTlsHostDetailed(domain.host);
const wwwHost = domain.host.startsWith('www.')
? domain.host
: `www.${domain.host}`;
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const apexOk = apexProbe.ok;
const bothOk = apexOk && wwwProbe.ok;
if (apexOk) {
const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe);
await this.prisma.domain.update({
where: { id: domain.id },
data: {
sslEnabled: true,
...(sslExpiresAt ? { sslExpiresAt } : {}),
},
});
if (bothOk) continue;
} else if (domain.sslEnabled) {
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: false, sslExpiresAt: null },
});
}
targets.push(domain);
}
const issued: string[] = [];
const failed: Array<{ host: string; error: string }> = [];
for (const domain of targets) {
try {
await this.websiteDeployAgent.issueSsl({
host: domain.host,
slug: domain.deploySlug,
});
const apexProbe = await probeTlsHostDetailed(domain.host);
const wwwHost = domain.host.startsWith('www.')
? domain.host
: `www.${domain.host}`;
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const apexOk = apexProbe.ok;
const sslExpiresAt = apexOk
? earliestTlsExpiry(apexProbe, wwwProbe)
: null;
await this.prisma.domain.update({
where: { id: domain.id },
data: {
sslEnabled: apexOk,
sslExpiresAt,
},
});
if (apexOk) {
issued.push(domain.host);
} else {
failed.push({
host: domain.host,
error: 'Certbot finished but TLS probe still failed on apex',
});
}
} catch (err) {
failed.push({
host: domain.host,
error: err instanceof Error ? err.message : 'SSL issue failed',
});
}
}
const parkedTargets = await this.prisma.domain.findMany({
where: { isActive: true, parkedHosts: { isEmpty: false } },
select: { parkedHosts: true },
});
const parkedSeen = new Set<string>();
for (const row of parkedTargets) {
for (const parked of uniqueParkedHosts(row.parkedHosts ?? [])) {
if (parkedSeen.has(parked)) continue;
parkedSeen.add(parked);
const apexOk = await probeTlsHost(parked);
const wwwOk = await probeTlsHost(`www.${parked}`);
if (apexOk && wwwOk) continue;
try {
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow =
(await probeTlsHost(parked)) && (await probeTlsHost(`www.${parked}`));
if (okNow) issued.push(parked);
else {
failed.push({
host: parked,
error: 'Certbot finished but parked TLS probe still failed',
});
}
} catch (err) {
failed.push({
host: parked,
error: err instanceof Error ? err.message : 'Parked SSL issue failed',
});
}
}
}
if (targets.length === 0 && issued.length === 0 && failed.length === 0) {
return {
status: 'ok' as const,
message: 'No storefront or parked domains need SSL',
issued,
failed,
};
}
const message =
failed.length === 0
? `Issued SSL for ${issued.length} website(s)`
: `Issued ${issued.length}, failed ${failed.length} website SSL`;
return { status: 'ok' as const, message, issued, failed };
}
/**
* Per-row SSL ensure: probe apex + www + business.* + customer.*.
* Issue storefront SSL on websites VM when apex/www fail (deploy_slug required).
* Expand dashboard cert when business/customer fail.
* Skip hosts that already have valid TLS.
*/
async issueSsl(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const apex = domain.host.trim().toLowerCase();
const wwwHost = apex.startsWith('www.') ? apex : `www.${apex}`;
const businessHost = `business.${apex}`;
const customerHost = `customer.${apex}`;
const slug = domain.deploySlug?.trim() || null;
type HostStatus = 'ok' | 'issued' | 'failed' | 'skipped';
type HostResult = { host: string; status: HostStatus; detail?: string };
const hosts: {
apex: HostResult;
www: HostResult;
business: HostResult;
customer: HostResult;
} = {
apex: { host: apex, status: 'ok' },
www: { host: wwwHost, status: 'ok' },
business: { host: businessHost, status: 'ok' },
customer: { host: customerHost, status: 'ok' },
};
let apexProbe = await probeTlsHostDetailed(apex);
let wwwProbe = await probeTlsHostDetailed(wwwHost);
let businessOk = await probeTlsHost(businessHost);
let customerOk = await probeTlsHost(customerHost);
let apexOk = apexProbe.ok;
let wwwOk = wwwProbe.ok;
const storefrontNeedsIssue = !apexOk || !wwwOk;
if (!storefrontNeedsIssue) {
hosts.apex = { host: apex, status: 'ok', detail: 'Already valid' };
hosts.www = { host: wwwHost, status: 'ok', detail: 'Already valid' };
} else if (!slug) {
hosts.apex = {
host: apex,
status: apexOk ? 'ok' : 'skipped',
detail: apexOk
? 'Already valid'
: 'No storefront deploy — apex SSL needs git/deploy on the websites VM',
};
hosts.www = {
host: wwwHost,
status: wwwOk ? 'ok' : 'skipped',
detail: wwwOk
? 'Already valid'
: 'No storefront deploy — www SSL needs git/deploy on the websites VM',
};
} else {
try {
await this.websiteDeployAgent.issueSsl({ host: apex, slug });
apexProbe = await probeTlsHostDetailed(apex);
wwwProbe = await probeTlsHostDetailed(wwwHost);
apexOk = apexProbe.ok;
wwwOk = wwwProbe.ok;
hosts.apex = apexOk
? {
host: apex,
status: 'issued',
detail: 'Issued on websites VM',
}
: {
host: apex,
status: 'failed',
detail: 'Certbot ran but HTTPS probe still failed — check DNS for apex',
};
hosts.www = wwwOk
? {
host: wwwHost,
status: 'issued',
detail: 'Issued on websites VM',
}
: {
host: wwwHost,
status: 'failed',
detail: 'Certbot ran but HTTPS probe still failed — check DNS for www',
};
} catch (err) {
const detail = err instanceof Error ? err.message : 'Storefront SSL issue failed';
hosts.apex = {
host: apex,
status: apexOk ? 'ok' : 'failed',
detail: apexOk ? 'Already valid' : detail,
};
hosts.www = {
host: wwwHost,
status: wwwOk ? 'ok' : 'failed',
detail: wwwOk ? 'Already valid' : detail,
};
}
}
if (businessOk) {
hosts.business = { host: businessHost, status: 'ok', detail: 'Already valid' };
}
if (customerOk) {
hosts.customer = { host: customerHost, status: 'ok', detail: 'Already valid' };
}
if (!businessOk || !customerOk) {
try {
await this.callDashboardSslSync({ wait: true, tenantApex: apex });
businessOk = await probeTlsHost(businessHost);
customerOk = await probeTlsHost(customerHost);
if (!hosts.business.detail) {
hosts.business = businessOk
? {
host: businessHost,
status: 'issued',
detail: 'Dashboard cert issued for this domain only',
}
: {
host: businessHost,
status: 'failed',
detail: 'Tenant dashboard SSL finished but probe still failed',
};
}
if (!hosts.customer.detail) {
hosts.customer = customerOk
? {
host: customerHost,
status: 'issued',
detail: 'Dashboard cert issued for this domain only',
}
: {
host: customerHost,
status: 'failed',
detail: 'Tenant dashboard SSL finished but probe still failed',
};
}
} catch (err) {
const detail =
err instanceof Error ? err.message : 'Tenant dashboard SSL failed';
if (!businessOk) {
hosts.business = { host: businessHost, status: 'failed', detail };
}
if (!customerOk) {
hosts.customer = { host: customerHost, status: 'failed', detail };
}
}
}
// List badge follows apex. www can still fail (no DNS) without marking Invalid.
if (apexOk && !apexProbe.expiresAt) {
apexProbe = await probeTlsHostDetailed(apex);
apexOk = apexProbe.ok;
}
const sslExpiresAt = apexOk
? earliestTlsExpiry(apexProbe, wwwOk ? wwwProbe : apexProbe)
: null;
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
sslEnabled: apexOk,
sslExpiresAt: apexOk ? sslExpiresAt : null,
},
});
const parkedResults: HostResult[] = [];
for (const parked of uniqueParkedHosts(domain.parkedHosts ?? [])) {
const parkedWww = `www.${parked}`;
const parkedApexOk = await probeTlsHost(parked);
const parkedWwwOk = await probeTlsHost(parkedWww);
if (parkedApexOk && parkedWwwOk) {
parkedResults.push({
host: parked,
status: 'ok',
detail: 'Already valid',
});
continue;
}
try {
await this.websiteDeployAgent.park({
host: parked,
canonicalHost: apex.replace(/^www\./, ''),
});
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow = (await probeTlsHost(parked)) && (await probeTlsHost(parkedWww));
parkedResults.push({
host: parked,
status: okNow ? 'issued' : 'failed',
detail: okNow
? 'Parked alias SSL issued on websites VM'
: 'Certbot ran but parked HTTPS probe still failed',
});
} catch (err) {
parkedResults.push({
host: parked,
status: 'failed',
detail: err instanceof Error ? err.message : 'Parked SSL failed',
});
}
}
const parts = [...[hosts.apex, hosts.www, hosts.business, hosts.customer], ...parkedResults];
const failed = parts.filter((p) => p.status === 'failed');
const issued = parts.filter((p) => p.status === 'issued');
// Always spell out each host so dashboard-only OK is never mistaken for storefront OK.
const message = parts
.map((p) => `${p.host}: ${p.status}${p.detail ? ` (${p.detail})` : ''}`)
.join(' · ');
return {
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
host: apex,
sslEnabled: updated.sslEnabled,
sslExpiresAt: updated.sslExpiresAt?.toISOString() ?? null,
hosts,
issued: issued.map((p) => p.host),
failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })),
message,
};
}
async deploy(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const slug = domain.deploySlug?.trim() || null;
if (!slug) {
throw new BadRequestException('This domain has no storefront deploy configured');
}
const markDeploy = async (status: 'started' | 'success' | 'failed') => {
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
lastDeployedAt: new Date(),
lastDeployStatus: status,
},
});
return updated;
};
await markDeploy('started');
try {
const result = await this.websiteDeployAgent.deploy(slug, { wait: true });
const updated = await markDeploy(
result.status === 'success' || result.status === 'accepted'
? 'success'
: 'failed',
);
return {
status: 'ok' as const,
slug,
host: domain.host,
message:
result.detail?.trim() ||
(updated.lastDeployStatus === 'success'
? 'Deploy succeeded on websites server'
: 'Deploy finished with unknown status'),
lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null,
lastDeployStatus: updated.lastDeployStatus,
};
} catch (err) {
await markDeploy('failed');
if (err instanceof ServiceUnavailableException) {
throw err;
}
throw new ServiceUnavailableException(
err instanceof Error ? err.message : 'Deploy failed',
);
}
}
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const host = (dto.host?.trim() || domain.host).toLowerCase();
const gitRepoUrl = dto.gitRepoUrl?.trim() || null;
if (dto.host) {
const existing = await this.prisma.domain.findUnique({ where: { host } });
if (existing && existing.id !== domainId) {
throw new ConflictException('Domain host is already taken');
}
}
if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) {
throw new BadRequestException(
'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)',
);
}
let deploySlug = domain.deploySlug;
let provisionError: string | null = null;
let nextGitRepoUrl = domain.gitRepoUrl;
const hostChanged = domain.host !== host;
const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null;
const alreadyWired =
!!effectiveGitRepoUrl &&
!!domain.deploySlug?.trim() &&
(!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) &&
!hostChanged;
if (effectiveGitRepoUrl && !alreadyWired) {
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
if (!slug) {
throw new BadRequestException('Could not derive deploy slug from host');
}
const slugTaken = await this.prisma.domain.findFirst({
where: { deploySlug: slug, NOT: { id: domainId } },
select: { id: true },
});
if (slugTaken) {
throw new ConflictException(`Deploy slug "${slug}" is already in use`);
}
try {
await this.websiteDeployAgent.provision({
slug,
host,
gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl),
});
deploySlug = slug;
nextGitRepoUrl = effectiveGitRepoUrl;
} catch (err) {
if (
err &&
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string'
) {
provisionError = err.message;
} else {
provisionError = 'Storefront provision failed on websites server';
}
}
} else if (alreadyWired) {
deploySlug = domain.deploySlug;
nextGitRepoUrl = effectiveGitRepoUrl;
}
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
host,
expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined,
...(gitRepoUrl && !provisionError
? { deploySlug, gitRepoUrl: nextGitRepoUrl }
: {}),
},
});
// 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,
provisionError,
dnsError,
};
}
async setParkedHosts(domainIdRaw: string, dto: UpdateParkedHostsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const canonical = domain.host.trim().toLowerCase().replace(/^www\./, '');
const nextAliases = uniqueParkedAliases(dto.aliases ?? []);
const currentDns = parseParkedDnsMap(domain.parkedDns);
const currentHosts = uniqueParkedHosts(domain.parkedHosts ?? []);
const currentAliases = currentHosts.map((host) => ({
host,
dns: currentDns[host] ?? ('arvan' as ParkedDnsProvider),
}));
for (const alias of nextAliases) {
if (!isValidParkedHost(alias.host)) {
throw new BadRequestException(`Invalid parked domain: ${alias.host}`);
}
if (alias.host === canonical) {
throw new BadRequestException('Parked domain cannot be the same as the main domain');
}
if (/^(business|customer|api)\./.test(alias.host)) {
throw new BadRequestException(
`Parked domain cannot be a dashboard/API host: ${alias.host}`,
);
}
}
const nextHosts = nextAliases.map((item) => item.host);
for (const host of nextHosts) {
const clash = await this.prisma.domain.findFirst({
where: {
NOT: { id: domainId },
OR: [{ host }, { parkedHosts: { has: host } }],
},
select: { host: true },
});
if (clash) {
throw new ConflictException(
`"${host}" is already used by ${clash.host}`,
);
}
}
const currentSet = new Set(currentHosts);
const nextSet = new Set(nextHosts);
const currentDnsByHost = new Map(currentAliases.map((item) => [item.host, item.dns]));
const nextDnsByHost = new Map(nextAliases.map((item) => [item.host, item.dns]));
const toAdd = nextHosts.filter((h) => !currentSet.has(h));
const toRemove = currentHosts.filter((h) => !nextSet.has(h));
const toRefreshDns = nextHosts.filter(
(h) => currentSet.has(h) && currentDnsByHost.get(h) !== nextDnsByHost.get(h),
);
const results: Array<{
host: string;
action: 'add' | 'remove' | 'keep';
ok: boolean;
dns?: string | null;
redirect?: string | null;
ssl?: string | null;
error?: string;
}> = [];
const saved = new Set(currentHosts);
for (const host of toRemove) {
try {
await this.websiteDeployAgent.unpark({ host });
saved.delete(host);
results.push({ host, action: 'remove', ok: true });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unpark failed';
results.push({ host, action: 'remove', ok: false, error });
}
}
for (const host of toAdd) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
if (dnsError) {
results.push({
host,
action: 'add',
ok: false,
dns: dnsError,
error: dnsError,
});
continue;
}
try {
await this.websiteDeployAgent.park({ host, canonicalHost: canonical });
} catch (err) {
const error = err instanceof Error ? err.message : 'Redirect vhost failed';
results.push({
host,
action: 'add',
ok: false,
dns: 'ok',
redirect: error,
error,
});
continue;
}
let sslDetail: string | null = 'issued';
try {
await this.websiteDeployAgent.issueSsl({ host });
} catch (err) {
sslDetail = err instanceof Error ? err.message : 'SSL issue failed';
}
saved.add(host);
results.push({
host,
action: 'add',
ok: sslDetail === 'issued',
dns: 'ok',
redirect: 'ok',
ssl: sslDetail,
...(sslDetail === 'issued' ? {} : { error: sslDetail }),
});
}
for (const host of toRefreshDns) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
results.push({
host,
action: 'keep',
ok: !dnsError,
dns: dnsError || 'ok',
...(dnsError ? { error: dnsError } : {}),
});
}
for (const host of nextHosts) {
if (!toAdd.includes(host) && !toRefreshDns.includes(host)) {
results.push({ host, action: 'keep', ok: true });
}
}
const parkedHosts = [
...nextHosts.filter((h) => saved.has(h)),
...[...saved].filter((h) => !nextSet.has(h)),
];
const parkedDns: Record<string, ParkedDnsProvider> = {};
for (const alias of nextAliases) {
if (saved.has(alias.host)) parkedDns[alias.host] = alias.dns;
}
for (const host of [...saved]) {
if (!parkedDns[host]) parkedDns[host] = currentDnsByHost.get(host) ?? 'arvan';
}
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: { parkedHosts, parkedDns },
});
const parkedAliases = parkedHosts.map((host) => ({
host,
dns: parkedDns[host] ?? ('arvan' as const),
}));
const failed = results.filter((r) => !r.ok);
const message =
failed.length === 0
? parkedHosts.length
? `Parked domains updated (${parkedHosts.join(', ')}).`
: 'Parked domains cleared.'
: failed.map((f) => `${f.host}: ${f.error || 'failed'}`).join(' · ');
return {
host: updated.host,
parkedHosts,
parkedAliases,
results,
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
message,
};
}
private async applyParkedDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureParkedRecords(host);
} else {
await this.arvanDns.ensureParkedRecords(host);
}
return null;
} catch (err) {
return this.httpErrorMessage(
err,
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed',
);
}
}
private async applyTenantDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureTenantRecords(host);
} else {
await this.arvanDns.ensureTenantRecords(host);
}
return null;
} catch (err) {
return this.httpErrorMessage(
err,
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed',
);
}
}
private httpErrorMessage(err: unknown, fallback: string): string {
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 fallback;
}
async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
return this.prisma.domain.update({
where: { id: domainId },
data: { isActive: dto.isActive },
});
}
async toggleSsl(domainIdRaw: string, dto: ToggleSslDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
return this.prisma.domain.update({
where: { id: domainId },
data: {
sslEnabled: dto.sslEnabled,
...(dto.sslEnabled ? {} : { sslExpiresAt: null }),
},
});
}
async remove(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
await this.prisma.domain.delete({ where: { id: domainId } });
return { message: 'Domain removed' };
}
}