Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ async function getCustomDomains(): Promise<Set<string>> {
const trusts = await db.trust.findMany({
where: {
domain: { not: null },
domainVerified: true,
status: 'published',
},
select: { domain: true },
Expand Down
179 changes: 144 additions & 35 deletions apps/api/src/trust-portal/trust-portal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VercelDomainResponse>(
`/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,
Expand All @@ -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<VercelDomainConfigResponse>(`/v6/domains/${domain}/config`, {
.get<VercelDomainConfigResponse>(`/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, {
params: {
teamId: process.env.VERCEL_TEAM_ID,
},
Expand Down Expand Up @@ -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`,
Expand All @@ -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,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale domainVerified preserved when Vercel status regresses

Medium Severity

In the alreadyOnProject branch, domainVerified is carried over from the DB (line 750–753) and stored in the upsert without considering the fresh Vercel state. If a previously verified domain now has statusData.verified === false (e.g., domain moved to another Vercel account), the DB ends up with domainVerified: true AND isVercelDomain: true simultaneously. The CORS query in auth.server.ts trusts domainVerified: true, so the domain stays in the allow-list even though Vercel can't serve it. The return value needsVerification: false also hides the verification UI from the user.

Additional Locations (1)
Fix in Cursor Fix in Web

}

this.logger.log(`Adding domain to Vercel project: ${domain}`);
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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),
]);
Expand All @@ -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;
Expand Down Expand Up @@ -993,7 +1086,7 @@ export class TrustPortalService {
});
}

const requiresVercelTxt = trustRecord?.isVercelDomain === true;
const requiresVercelTxt = liveIsVercelDomain;
const isVerified =
isCnameVerified &&
isTxtVerified &&
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -182,7 +194,7 @@ export function TrustPortalDomain({
<FormLabel className="flex items-center gap-2">
Custom Domain
{initialDomain !== '' &&
(domainVerified ? (
(isEffectivelyVerified ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
Expand Down Expand Up @@ -215,7 +227,7 @@ export function TrustPortalDomain({
disabled={!canUpdate}
/>
</FormControl>
{field.value === initialDomain && initialDomain !== '' && !domainVerified && (
{field.value === initialDomain && initialDomain !== '' && !isEffectivelyVerified && (
<Button
type="button"
className="md:max-w-[300px]"
Expand All @@ -236,7 +248,7 @@ export function TrustPortalDomain({

{form.watch('domain') === initialDomain &&
initialDomain !== '' &&
!domainVerified && (
!isEffectivelyVerified && (
<div className="space-y-2 pt-2">
{verificationInfo && (
<div className="rounded-md border border-amber-200 bg-amber-100 p-4 dark:border-amber-900 dark:bg-amber-950">
Expand Down Expand Up @@ -354,7 +366,7 @@ export function TrustPortalDomain({
</div>
</td>
</tr>
{isVercelDomain && (
{needsVercelTxt && (
<tr className="border-t [&_td]:px-3 [&_td]:py-2">
<td>
{isVercelTxtVerified ? (
Expand All @@ -371,7 +383,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"
>
<ClipboardCopy className="h-4 w-4" />
Expand All @@ -381,13 +393,13 @@ export function TrustPortalDomain({
<td>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 break-words">
{vercelVerification}
{effectiveVercelTxtValue}
</span>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleCopy(vercelVerification || '', 'Value')}
onClick={() => handleCopy(effectiveVercelTxtValue || '', 'Value')}
className="h-6 w-6 shrink-0"
>
<ClipboardCopy className="h-4 w-4" />
Expand Down Expand Up @@ -475,7 +487,7 @@ export function TrustPortalDomain({
</Button>
</div>
</div>
{isVercelDomain && (
{needsVercelTxt && (
<>
<div className="border-b" />
<div>
Expand All @@ -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"
>
<ClipboardCopy className="h-4 w-4" />
Expand All @@ -500,12 +512,12 @@ export function TrustPortalDomain({
<div>
<div className="mb-1 font-medium">Value:</div>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 break-words">{vercelVerification}</span>
<span className="min-w-0 break-words">{effectiveVercelTxtValue}</span>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleCopy(vercelVerification || '', 'Value')}
onClick={() => handleCopy(effectiveVercelTxtValue || '', 'Value')}
className="h-6 w-6 shrink-0"
>
<ClipboardCopy className="h-4 w-4" />
Expand Down
Loading