Skip to content

Commit c21c656

Browse files
fix: fix trust portal domain verification
[dev] [Marfuen] mariano/swap-onboarding-cloud-software-order
1 parent d65a8a9 commit c21c656

File tree

3 files changed

+168
-46
lines changed

3 files changed

+168
-46
lines changed

apps/api/src/auth/auth.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ async function getCustomDomains(): Promise<Set<string>> {
110110
const trusts = await db.trust.findMany({
111111
where: {
112112
domain: { not: null },
113+
domainVerified: true,
113114
status: 'published',
114115
},
115116
select: { domain: true },

apps/api/src/trust-portal/trust-portal.service.ts

Lines changed: 144 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export class TrustPortalService {
183183
// Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain}
184184
const [domainResponse, configResponse] = await Promise.all([
185185
this.vercelApi.get<VercelDomainResponse>(
186-
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`,
186+
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`,
187187
{
188188
params: {
189189
teamId: process.env.VERCEL_TEAM_ID,
@@ -193,7 +193,7 @@ export class TrustPortalService {
193193
// Get domain config to retrieve the actual CNAME target
194194
// Vercel API endpoint: GET /v6/domains/{domain}/config
195195
this.vercelApi
196-
.get<VercelDomainConfigResponse>(`/v6/domains/${domain}/config`, {
196+
.get<VercelDomainConfigResponse>(`/v6/domains/${TrustPortalService.safeDomainPath(domain)}/config`, {
197197
params: {
198198
teamId: process.env.VERCEL_TEAM_ID,
199199
},
@@ -752,6 +752,20 @@ export class TrustPortalService {
752752
? currentTrust.domainVerified
753753
: false;
754754

755+
// Remove old domain from Vercel if switching to a different one
756+
if (currentTrust?.domain && currentTrust.domain !== domain) {
757+
try {
758+
await this.vercelApi.delete(
759+
`/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(currentTrust.domain)}`,
760+
{ params: { teamId } },
761+
);
762+
} catch (error) {
763+
this.logger.warn(
764+
`Failed to remove old domain ${currentTrust.domain} from Vercel: ${error}`,
765+
);
766+
}
767+
}
768+
755769
// Check if domain already exists on the Vercel project
756770
const existingDomainsResp = await this.vercelApi.get(
757771
`/v9/projects/${projectId}/domains`,
@@ -761,22 +775,54 @@ export class TrustPortalService {
761775
const existingDomains: Array<{ name: string }> =
762776
existingDomainsResp.data?.domains ?? [];
763777

764-
if (existingDomains.some((d) => d.name === domain)) {
765-
const domainOwner = await db.trust.findUnique({
766-
where: { organizationId, domain },
778+
const alreadyOnProject = existingDomains.some((d) => d.name === domain);
779+
780+
if (alreadyOnProject) {
781+
const domainOwner = await db.trust.findFirst({
782+
where: { domain, organizationId: { not: organizationId } },
783+
select: { organizationId: true },
767784
});
768785

769-
if (!domainOwner || domainOwner.organizationId === organizationId) {
770-
await this.vercelApi.delete(
771-
`/v9/projects/${projectId}/domains/${domain}`,
772-
{ params: { teamId } },
773-
);
774-
} else {
786+
if (domainOwner) {
775787
return {
776788
success: false,
777789
error: 'Domain is already in use by another organization',
778790
};
779791
}
792+
793+
// Domain already on Vercel for this org — fetch current status
794+
// instead of deleting and re-adding (which regenerates verification tokens)
795+
const statusResp = await this.vercelApi.get(
796+
`/v9/projects/${projectId}/domains/${TrustPortalService.safeDomainPath(domain)}`,
797+
{ params: { teamId } },
798+
);
799+
800+
const statusData = statusResp.data;
801+
const isVercelDomain = statusData.verified === false;
802+
const vercelVerification =
803+
statusData.verification?.[0]?.value || null;
804+
805+
await db.trust.upsert({
806+
where: { organizationId },
807+
update: {
808+
domain,
809+
domainVerified,
810+
isVercelDomain,
811+
vercelVerification,
812+
},
813+
create: {
814+
organizationId,
815+
domain,
816+
domainVerified: false,
817+
isVercelDomain,
818+
vercelVerification,
819+
},
820+
});
821+
822+
return {
823+
success: true,
824+
needsVerification: !domainVerified,
825+
};
780826
}
781827

782828
this.logger.log(`Adding domain to Vercel project: ${domain}`);
@@ -891,6 +937,11 @@ export class TrustPortalService {
891937
}
892938
}
893939

940+
/** Encode a validated domain for safe use in URL path segments. */
941+
private static safeDomainPath(domain: string): string {
942+
return encodeURIComponent(domain);
943+
}
944+
894945
/**
895946
* DNS CNAME patterns for Vercel verification.
896947
*/
@@ -902,20 +953,30 @@ export class TrustPortalService {
902953
async checkDnsRecords(organizationId: string, domain: string) {
903954
this.validateDomain(domain);
904955

956+
// Verify the domain belongs to this organization
957+
const trustRecord = await db.trust.findUnique({
958+
where: { organizationId },
959+
});
960+
if (!trustRecord || trustRecord.domain !== domain) {
961+
throw new BadRequestException(
962+
'Domain does not match the configured domain for this organization',
963+
);
964+
}
965+
905966
const rootDomain = domain.split('.').slice(-2).join('.');
906967

907968
const [cnameResp, txtResp, vercelTxtResp] = await Promise.all([
908969
axios
909-
.get(`https://networkcalc.com/api/dns/lookup/${domain}`)
970+
.get(`https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(domain)}`)
910971
.catch(() => null),
911972
axios
912973
.get(
913-
`https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`,
974+
`https://networkcalc.com/api/dns/lookup/${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`,
914975
)
915976
.catch(() => null),
916977
axios
917978
.get(
918-
`https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`,
979+
`https://networkcalc.com/api/dns/lookup/_vercel.${TrustPortalService.safeDomainPath(rootDomain)}?type=TXT`,
919980
)
920981
.catch(() => null),
921982
]);
@@ -937,13 +998,45 @@ export class TrustPortalService {
937998
const txtRecords = txtResp.data?.records?.TXT;
938999
const vercelTxtRecords = vercelTxtResp?.data?.records?.TXT;
9391000

940-
const trustRecord = await db.trust.findUnique({
941-
where: { organizationId, domain },
942-
select: { isVercelDomain: true, vercelVerification: true },
943-
});
1001+
// Fetch fresh verification state from Vercel instead of relying on
1002+
// potentially stale DB values (tokens change if domain was re-added).
1003+
let liveIsVercelDomain = false;
1004+
let liveVercelVerification: string | null = null;
1005+
1006+
if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) {
1007+
try {
1008+
const vercelStatusResp = await this.vercelApi.get(
1009+
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}`,
1010+
{ params: { teamId: process.env.VERCEL_TEAM_ID } },
1011+
);
1012+
const vercelData = vercelStatusResp.data;
1013+
liveIsVercelDomain = vercelData.verified === false;
1014+
liveVercelVerification =
1015+
vercelData.verification?.[0]?.value || null;
1016+
1017+
// Sync DB with live Vercel state
1018+
await db.trust.update({
1019+
where: { organizationId },
1020+
data: {
1021+
isVercelDomain: liveIsVercelDomain,
1022+
vercelVerification: liveVercelVerification,
1023+
},
1024+
});
1025+
} catch (error) {
1026+
this.logger.warn(
1027+
`Failed to fetch live Vercel status for ${domain}, falling back to DB: ${error}`,
1028+
);
1029+
const trustRecord = await db.trust.findUnique({
1030+
where: { organizationId, domain },
1031+
select: { isVercelDomain: true, vercelVerification: true },
1032+
});
1033+
liveIsVercelDomain = trustRecord?.isVercelDomain === true;
1034+
liveVercelVerification = trustRecord?.vercelVerification ?? null;
1035+
}
1036+
}
9441037

9451038
const expectedTxtValue = `compai-domain-verification=${organizationId}`;
946-
const expectedVercelTxtValue = trustRecord?.vercelVerification;
1039+
const expectedVercelTxtValue = liveVercelVerification;
9471040

9481041
// Check CNAME
9491042
let isCnameVerified = false;
@@ -993,7 +1086,7 @@ export class TrustPortalService {
9931086
});
9941087
}
9951088

996-
const requiresVercelTxt = trustRecord?.isVercelDomain === true;
1089+
const requiresVercelTxt = liveIsVercelDomain;
9971090
const isVerified =
9981091
isCnameVerified &&
9991092
isTxtVerified &&
@@ -1010,34 +1103,50 @@ export class TrustPortalService {
10101103
};
10111104
}
10121105

1013-
await db.trust.upsert({
1014-
where: { organizationId, domain },
1015-
update: { domainVerified: true, status: 'published' },
1016-
create: {
1017-
organizationId,
1018-
domain,
1019-
status: 'published',
1020-
},
1021-
});
1022-
10231106
// Trigger Vercel to re-verify the domain so it provisions SSL and starts serving.
1024-
// Without this, Vercel doesn't know DNS has been configured and the domain stays inactive
1025-
// (previously required CS to manually click "Refresh" in Vercel dashboard).
1107+
let vercelVerified = false;
10261108
if (process.env.TRUST_PORTAL_PROJECT_ID && process.env.VERCEL_TEAM_ID) {
10271109
try {
1028-
await this.vercelApi.post(
1029-
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}/verify`,
1110+
const verifyResp = await this.vercelApi.post(
1111+
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${TrustPortalService.safeDomainPath(domain)}/verify`,
10301112
{},
10311113
{ params: { teamId: process.env.VERCEL_TEAM_ID } },
10321114
);
1115+
vercelVerified = verifyResp.data?.verified === true;
10331116
} catch (error) {
1034-
// Non-fatal — domain is verified on our side, Vercel will eventually pick it up
10351117
this.logger.warn(
10361118
`Failed to trigger Vercel domain verification for ${domain}: ${error}`,
10371119
);
10381120
}
10391121
}
10401122

1123+
// For cross-account domains (liveIsVercelDomain=true), Vercel must confirm
1124+
// the _vercel TXT record before the domain will serve traffic.
1125+
// For same-account domains, DNS verification is sufficient — Vercel will
1126+
// pick up the CNAME on its own, so don't block on the verify response.
1127+
const domainFullyVerified = requiresVercelTxt
1128+
? vercelVerified
1129+
: true;
1130+
1131+
await db.trust.update({
1132+
where: { organizationId },
1133+
data: {
1134+
domainVerified: domainFullyVerified,
1135+
...(domainFullyVerified ? { status: 'published' as const } : {}),
1136+
},
1137+
});
1138+
1139+
if (!domainFullyVerified) {
1140+
return {
1141+
success: false,
1142+
isCnameVerified,
1143+
isTxtVerified,
1144+
isVercelTxtVerified,
1145+
error:
1146+
'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.',
1147+
};
1148+
}
1149+
10411150
return {
10421151
success: true,
10431152
isCnameVerified,

apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalDomain.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ export function TrustPortalDomain({
6262
return null;
6363
}, [domainStatus]);
6464

65+
// Domain is truly verified only when both our DB and Vercel agree.
66+
// While Vercel data is loading, fall back to DB value to avoid flicker.
67+
const isVercelVerified = domainStatus?.data?.verified;
68+
const isEffectivelyVerified =
69+
domainVerified && (isVercelVerified === undefined || isVercelVerified);
70+
71+
// Show _vercel TXT row if DB says so OR live Vercel data has verification requirements
72+
const needsVercelTxt = isVercelDomain || verificationInfo !== null;
73+
74+
// Prefer live Vercel verification value over stale DB value
75+
const effectiveVercelTxtValue = verificationInfo?.value ?? vercelVerification;
76+
6577
// Get the actual CNAME target from Vercel, with fallback
6678
// Normalize to include trailing dot for DNS record display
6779
const cnameTarget = useMemo(() => {
@@ -182,7 +194,7 @@ export function TrustPortalDomain({
182194
<FormLabel className="flex items-center gap-2">
183195
Custom Domain
184196
{initialDomain !== '' &&
185-
(domainVerified ? (
197+
(isEffectivelyVerified ? (
186198
<TooltipProvider>
187199
<Tooltip>
188200
<TooltipTrigger type="button">
@@ -215,7 +227,7 @@ export function TrustPortalDomain({
215227
disabled={!canUpdate}
216228
/>
217229
</FormControl>
218-
{field.value === initialDomain && initialDomain !== '' && !domainVerified && (
230+
{field.value === initialDomain && initialDomain !== '' && !isEffectivelyVerified && (
219231
<Button
220232
type="button"
221233
className="md:max-w-[300px]"
@@ -236,7 +248,7 @@ export function TrustPortalDomain({
236248

237249
{form.watch('domain') === initialDomain &&
238250
initialDomain !== '' &&
239-
!domainVerified && (
251+
!isEffectivelyVerified && (
240252
<div className="space-y-2 pt-2">
241253
{verificationInfo && (
242254
<div className="rounded-md border border-amber-200 bg-amber-100 p-4 dark:border-amber-900 dark:bg-amber-950">
@@ -354,7 +366,7 @@ export function TrustPortalDomain({
354366
</div>
355367
</td>
356368
</tr>
357-
{isVercelDomain && (
369+
{needsVercelTxt && (
358370
<tr className="border-t [&_td]:px-3 [&_td]:py-2">
359371
<td>
360372
{isVercelTxtVerified ? (
@@ -371,7 +383,7 @@ export function TrustPortalDomain({
371383
variant="ghost"
372384
size="icon"
373385
type="button"
374-
onClick={() => handleCopy(vercelVerification || '', 'Name')}
386+
onClick={() => handleCopy('_vercel', 'Name')}
375387
className="h-6 w-6 shrink-0"
376388
>
377389
<ClipboardCopy className="h-4 w-4" />
@@ -381,13 +393,13 @@ export function TrustPortalDomain({
381393
<td>
382394
<div className="flex items-center justify-between gap-2">
383395
<span className="min-w-0 break-words">
384-
{vercelVerification}
396+
{effectiveVercelTxtValue}
385397
</span>
386398
<Button
387399
variant="ghost"
388400
size="icon"
389401
type="button"
390-
onClick={() => handleCopy(vercelVerification || '', 'Value')}
402+
onClick={() => handleCopy(effectiveVercelTxtValue || '', 'Value')}
391403
className="h-6 w-6 shrink-0"
392404
>
393405
<ClipboardCopy className="h-4 w-4" />
@@ -475,7 +487,7 @@ export function TrustPortalDomain({
475487
</Button>
476488
</div>
477489
</div>
478-
{isVercelDomain && (
490+
{needsVercelTxt && (
479491
<>
480492
<div className="border-b" />
481493
<div>
@@ -490,7 +502,7 @@ export function TrustPortalDomain({
490502
variant="ghost"
491503
size="icon"
492504
type="button"
493-
onClick={() => handleCopy(vercelVerification || '', 'Name')}
505+
onClick={() => handleCopy('_vercel', 'Name')}
494506
className="h-6 w-6 shrink-0"
495507
>
496508
<ClipboardCopy className="h-4 w-4" />
@@ -500,12 +512,12 @@ export function TrustPortalDomain({
500512
<div>
501513
<div className="mb-1 font-medium">Value:</div>
502514
<div className="flex items-center justify-between gap-2">
503-
<span className="min-w-0 break-words">{vercelVerification}</span>
515+
<span className="min-w-0 break-words">{effectiveVercelTxtValue}</span>
504516
<Button
505517
variant="ghost"
506518
size="icon"
507519
type="button"
508-
onClick={() => handleCopy(vercelVerification || '', 'Value')}
520+
onClick={() => handleCopy(effectiveVercelTxtValue || '', 'Value')}
509521
className="h-6 w-6 shrink-0"
510522
>
511523
<ClipboardCopy className="h-4 w-4" />

0 commit comments

Comments
 (0)