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(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('SSL_SYNC_AGENT_URL')?.trim(); const token = this.config.get('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(); 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 = {}; 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 { 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 { 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' }; } }