diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 865c84573..03b5b3b7f 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -110,6 +110,7 @@ async function getCustomDomains(): Promise> { const trusts = await db.trust.findMany({ where: { domain: { not: null }, + domainVerified: true, status: 'published', }, select: { domain: true }, diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f2ab6f6fc..430997d96 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -183,7 +183,7 @@ export class TrustPortalService { // Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain} const [domainResponse, configResponse] = await Promise.all([ this.vercelApi.get( - `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`, + `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, { params: { teamId: process.env.VERCEL_TEAM_ID, @@ -193,7 +193,7 @@ export class TrustPortalService { // Get domain config to retrieve the actual CNAME target // Vercel API endpoint: GET /v6/domains/{domain}/config this.vercelApi - .get(`/v6/domains/${domain}/config`, { + .get(`/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, { params: { teamId: process.env.VERCEL_TEAM_ID, }, @@ -752,6 +752,20 @@ export class TrustPortalService { ? currentTrust.domainVerified : false; + // Remove old domain from Vercel if switching to a different one + if (currentTrust?.domain && currentTrust.domain !== domain) { + try { + await this.vercelApi.delete( + `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(currentTrust.domain)}`, + { params: { teamId } }, + ); + } catch (error) { + this.logger.warn( + `Failed to remove old domain ${currentTrust.domain} from Vercel: ${error}`, + ); + } + } + // Check if domain already exists on the Vercel project const existingDomainsResp = await this.vercelApi.get( `/v9/projects/${projectId}/domains`, @@ -761,22 +775,54 @@ export class TrustPortalService { const existingDomains: Array<{ name: string }> = existingDomainsResp.data?.domains ?? []; - if (existingDomains.some((d) => d.name === domain)) { - const domainOwner = await db.trust.findUnique({ - where: { organizationId, domain }, + const alreadyOnProject = existingDomains.some((d) => d.name === domain); + + if (alreadyOnProject) { + const domainOwner = await db.trust.findFirst({ + where: { domain, organizationId: { not: organizationId } }, + select: { organizationId: true }, }); - if (!domainOwner || domainOwner.organizationId === organizationId) { - await this.vercelApi.delete( - `/v9/projects/${projectId}/domains/${domain}`, - { params: { teamId } }, - ); - } else { + if (domainOwner) { return { success: false, error: 'Domain is already in use by another organization', }; } + + // Domain already on Vercel for this org — fetch current status + // instead of deleting and re-adding (which regenerates verification tokens) + const statusResp = await this.vercelApi.get( + `/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(domain)}`, + { params: { teamId } }, + ); + + const statusData = statusResp.data; + const isVercelDomain = statusData.verified === false; + const vercelVerification = + statusData.verification?.[0]?.value || null; + + await db.trust.upsert({ + where: { organizationId }, + update: { + domain, + domainVerified, + isVercelDomain, + vercelVerification, + }, + create: { + organizationId, + domain, + domainVerified: false, + isVercelDomain, + vercelVerification, + }, + }); + + return { + success: true, + needsVerification: !domainVerified, + }; } this.logger.log(`Adding domain to Vercel project: ${domain}`); @@ -891,6 +937,11 @@ export class TrustPortalService { } } + /** Encode a validated domain for safe use in URL path segments. */ + private static safeDomainPath(domain: string): string { + return encodeURIComponent(domain); + } + /** * DNS CNAME patterns for Vercel verification. */ @@ -902,20 +953,30 @@ export class TrustPortalService { async checkDnsRecords(organizationId: string, domain: string) { this.validateDomain(domain); + // Verify the domain belongs to this organization + const trustRecord = await db.trust.findUnique({ + where: { organizationId }, + }); + if (!trustRecord || trustRecord.domain !== domain) { + throw new BadRequestException( + 'Domain does not match the configured domain for this organization', + ); + } + const rootDomain = domain.split('.').slice(-2).join('.'); const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([ axios - .get(`https://networkcalc.com/api/dns/lookup/${domain}`) + .get(`https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(domain)}`) .catch(() => null), axios .get( - `https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`, + `https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`, ) .catch(() => null), axios .get( - `https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`, + `https://networkcalc.com/api/dns/lookup/_vercel.${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`, ) .catch(() => null), ]); @@ -937,13 +998,45 @@ export class TrustPortalService { const txtRecords = txtResp.data?.records?.TXT; const vercelTxtRecords = vercelTxtResp?.data?.records?.TXT; - const trustRecord = await db.trust.findUnique({ - where: { organizationId, domain }, - select: { isVercelDomain: true, vercelVerification: true }, - }); + // Fetch fresh verification state from Vercel instead of relying on + // potentially stale DB values (tokens change if domain was re-added). + let liveIsVercelDomain = false; + let liveVercelVerification: string | null = null; + + if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { + try { + const vercelStatusResp = await this.vercelApi.get( + `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`, + { params: { teamId: process.env.VERCEL_TEAM_ID } }, + ); + const vercelData = vercelStatusResp.data; + liveIsVercelDomain = vercelData.verified === false; + liveVercelVerification = + vercelData.verification?.[0]?.value || null; + + // Sync DB with live Vercel state + await db.trust.update({ + where: { organizationId }, + data: { + isVercelDomain: liveIsVercelDomain, + vercelVerification: liveVercelVerification, + }, + }); + } catch (error) { + this.logger.warn( + `Failed to fetch live Vercel status for ${domain}, falling back to DB: ${error}`, + ); + const trustRecord = await db.trust.findUnique({ + where: { organizationId, domain }, + select: { isVercelDomain: true, vercelVerification: true }, + }); + liveIsVercelDomain = trustRecord?.isVercelDomain === true; + liveVercelVerification = trustRecord?.vercelVerification ?? null; + } + } const expectedTxtValue = `compai-domain-verification=${organizationId}`; - const expectedVercelTxtValue = trustRecord?.vercelVerification; + const expectedVercelTxtValue = liveVercelVerification; // Check CNAME let isCnameVerified = false; @@ -993,7 +1086,7 @@ export class TrustPortalService { }); } - const requiresVercelTxt = trustRecord?.isVercelDomain === true; + const requiresVercelTxt = liveIsVercelDomain; const isVerified = isCnameVerified && isTxtVerified && @@ -1010,34 +1103,50 @@ export class TrustPortalService { }; } - await db.trust.upsert({ - where: { organizationId, domain }, - update: { domainVerified: true, status: 'published' }, - create: { - organizationId, - domain, - status: 'published', - }, - }); - // Trigger Vercel to re-verify the domain so it provisions SSL and starts serving. - // Without this, Vercel doesn't know DNS has been configured and the domain stays inactive - // (previously required CS to manually click "Refresh" in Vercel dashboard). + let vercelVerified = false; if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) { try { - await this.vercelApi.post( - `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`, + const verifyResp = await this.vercelApi.post( + `/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}/verify`, {}, { params: { teamId: process.env.VERCEL_TEAM_ID } }, ); + vercelVerified = verifyResp.data?.verified === true; } catch (error) { - // Non-fatal — domain is verified on our side, Vercel will eventually pick it up this.logger.warn( `Failed to trigger Vercel domain verification for ${domain}: ${error}`, ); } } + // For cross-account domains (liveIsVercelDomain=true), Vercel must confirm + // the _vercel TXT record before the domain will serve traffic. + // For same-account domains, DNS verification is sufficient — Vercel will + // pick up the CNAME on its own, so don't block on the verify response. + const domainFullyVerified = requiresVercelTxt + ? vercelVerified + : true; + + await db.trust.update({ + where: { organizationId }, + data: { + domainVerified: domainFullyVerified, + ...(domainFullyVerified ? { status: 'published' as const } : {}), + }, + }); + + if (!domainFullyVerified) { + return { + success: false, + isCnameVerified, + isTxtVerified, + isVercelTxtVerified, + error: + 'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.', + }; + } + return { success: true, isCnameVerified, diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx index a79011beb..0265974c8 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx @@ -62,6 +62,18 @@ export function TrustPortalDomain({ return null; }, [domainStatus]); + // Domain is truly verified only when both our DB and Vercel agree. + // While Vercel data is loading, fall back to DB value to avoid flicker. + const isVercelVerified = domainStatus?.data?.verified; + const isEffectivelyVerified = + domainVerified && (isVercelVerified === undefined || isVercelVerified); + + // Show _vercel TXT row if DB says so OR live Vercel data has verification requirements + const needsVercelTxt = isVercelDomain || verificationInfo !== null; + + // Prefer live Vercel verification value over stale DB value + const effectiveVercelTxtValue = verificationInfo?.value ?? vercelVerification; + // Get the actual CNAME target from Vercel, with fallback // Normalize to include trailing dot for DNS record display const cnameTarget = useMemo(() => { @@ -182,7 +194,7 @@ export function TrustPortalDomain({ Custom Domain {initialDomain !== '' && - (domainVerified ? ( + (isEffectivelyVerified ? ( @@ -215,7 +227,7 @@ export function TrustPortalDomain({ disabled={!canUpdate} /> - {field.value === initialDomain && initialDomain !== '' && !domainVerified && ( + {field.value === initialDomain && initialDomain !== '' && !isEffectivelyVerified && ( - {isVercelDomain && ( + {needsVercelTxt && ( <>
@@ -490,7 +502,7 @@ export function TrustPortalDomain({ variant="ghost" size="icon" type="button" - onClick={() => handleCopy(vercelVerification || '', 'Name')} + onClick={() => handleCopy('_vercel', 'Name')} className="h-6 w-6 shrink-0" > @@ -500,12 +512,12 @@ export function TrustPortalDomain({
Value:
- {vercelVerification} + {effectiveVercelTxtValue}