Skip to content

Commit eab60fe

Browse files
fix: fix missing DNS records coming from Vercel
[dev] [Marfuen] mariano/fix-dns-vercel-missing-record
1 parent 218b0a6 commit eab60fe

File tree

10 files changed

+516
-86
lines changed

10 files changed

+516
-86
lines changed
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { decideDomainVerification, deriveDnsVerified } from './domain-verification';
2+
3+
describe('decideDomainVerification', () => {
4+
const baseInputs = {
5+
isCnameVerified: true,
6+
isTxtVerified: true,
7+
isVercelTxtVerified: true,
8+
requiresVercelTxt: false,
9+
vercelAvailable: true,
10+
vercelMisconfigured: false as boolean | null,
11+
vercelVerifiedAfterTrigger: true as boolean | null,
12+
};
13+
14+
it('returns success=true when all DNS passes and Vercel reports not misconfigured', () => {
15+
expect(decideDomainVerification(baseInputs).success).toBe(true);
16+
});
17+
18+
it('returns success=false when Vercel reports misconfigured, even if DNS regex passes', () => {
19+
const result = decideDomainVerification({
20+
...baseInputs,
21+
vercelMisconfigured: true,
22+
});
23+
expect(result.success).toBe(false);
24+
expect(result.error?.toLowerCase()).toContain('misconfigured');
25+
});
26+
27+
it('returns success=false when Vercel config fetch could not confirm status (null)', () => {
28+
const result = decideDomainVerification({
29+
...baseInputs,
30+
vercelMisconfigured: null,
31+
});
32+
expect(result.success).toBe(false);
33+
expect(result.error?.toLowerCase()).toMatch(/vercel|try again/);
34+
});
35+
36+
it('returns success=false when the CNAME record is missing or wrong', () => {
37+
const result = decideDomainVerification({
38+
...baseInputs,
39+
isCnameVerified: false,
40+
});
41+
expect(result.success).toBe(false);
42+
});
43+
44+
it('returns success=false when the domain TXT record is missing or wrong', () => {
45+
const result = decideDomainVerification({
46+
...baseInputs,
47+
isTxtVerified: false,
48+
});
49+
expect(result.success).toBe(false);
50+
});
51+
52+
it('does not require _vercel TXT verification when cross-account verification is not required', () => {
53+
expect(
54+
decideDomainVerification({
55+
...baseInputs,
56+
isVercelTxtVerified: false,
57+
requiresVercelTxt: false,
58+
}).success,
59+
).toBe(true);
60+
});
61+
62+
it('requires _vercel TXT verification when cross-account verification is required', () => {
63+
expect(
64+
decideDomainVerification({
65+
...baseInputs,
66+
requiresVercelTxt: true,
67+
isVercelTxtVerified: false,
68+
}).success,
69+
).toBe(false);
70+
});
71+
72+
it('requires Vercel verify response when cross-account verification is required', () => {
73+
expect(
74+
decideDomainVerification({
75+
...baseInputs,
76+
requiresVercelTxt: true,
77+
isVercelTxtVerified: true,
78+
vercelVerifiedAfterTrigger: false,
79+
}).success,
80+
).toBe(false);
81+
});
82+
83+
it('prioritizes DNS error over Vercel misconfiguration error when both fail', () => {
84+
const result = decideDomainVerification({
85+
...baseInputs,
86+
isCnameVerified: false,
87+
vercelMisconfigured: true,
88+
});
89+
expect(result.success).toBe(false);
90+
expect(result.error?.toLowerCase()).toMatch(/dns|record/);
91+
});
92+
93+
it('when Vercel is not configured on the server, trusts DNS alone (dev/self-host scenario)', () => {
94+
const result = decideDomainVerification({
95+
...baseInputs,
96+
vercelAvailable: false,
97+
vercelMisconfigured: null,
98+
vercelVerifiedAfterTrigger: null,
99+
});
100+
expect(result.success).toBe(true);
101+
});
102+
103+
it('when Vercel is not configured, still requires DNS records to match', () => {
104+
const result = decideDomainVerification({
105+
...baseInputs,
106+
vercelAvailable: false,
107+
vercelMisconfigured: null,
108+
vercelVerifiedAfterTrigger: null,
109+
isCnameVerified: false,
110+
});
111+
expect(result.success).toBe(false);
112+
});
113+
114+
describe('transient flag (to avoid de-verifying working domains)', () => {
115+
it('marks failure as transient when Vercel is reachable but config fetch failed', () => {
116+
const result = decideDomainVerification({
117+
...baseInputs,
118+
vercelAvailable: true,
119+
vercelMisconfigured: null,
120+
});
121+
expect(result.success).toBe(false);
122+
expect(result.transient).toBe(true);
123+
});
124+
125+
it('marks failure as NOT transient when Vercel explicitly reports misconfigured', () => {
126+
const result = decideDomainVerification({
127+
...baseInputs,
128+
vercelMisconfigured: true,
129+
});
130+
expect(result.success).toBe(false);
131+
expect(result.transient).toBe(false);
132+
});
133+
134+
it('marks failure as NOT transient when DNS records are clearly wrong', () => {
135+
const result = decideDomainVerification({
136+
...baseInputs,
137+
isCnameVerified: false,
138+
});
139+
expect(result.success).toBe(false);
140+
expect(result.transient).toBe(false);
141+
});
142+
143+
it('marks failure as transient when cross-account verify returns null (not explicitly false)', () => {
144+
const result = decideDomainVerification({
145+
...baseInputs,
146+
requiresVercelTxt: true,
147+
vercelVerifiedAfterTrigger: null,
148+
});
149+
expect(result.success).toBe(false);
150+
expect(result.transient).toBe(true);
151+
});
152+
153+
it('does not mark success as transient', () => {
154+
const result = decideDomainVerification(baseInputs);
155+
expect(result.success).toBe(true);
156+
expect(result.transient).toBeFalsy();
157+
});
158+
});
159+
});
160+
161+
describe('deriveDnsVerified', () => {
162+
it('returns true when Vercel reports the domain is not misconfigured (CNAME)', () => {
163+
expect(
164+
deriveDnsVerified({
165+
dnsRegexMatches: true,
166+
vercelMisconfigured: false,
167+
}),
168+
).toBe(true);
169+
});
170+
171+
it('returns true when Vercel reports the domain is not misconfigured (A record / apex)', () => {
172+
expect(
173+
deriveDnsVerified({
174+
dnsRegexMatches: false,
175+
vercelMisconfigured: false,
176+
}),
177+
).toBe(true);
178+
});
179+
180+
it('returns false when Vercel reports misconfigured, even if our DNS regex matches', () => {
181+
expect(
182+
deriveDnsVerified({
183+
dnsRegexMatches: true,
184+
vercelMisconfigured: true,
185+
}),
186+
).toBe(false);
187+
});
188+
189+
it('falls back to DNS regex when Vercel data is not available', () => {
190+
expect(
191+
deriveDnsVerified({
192+
dnsRegexMatches: true,
193+
vercelMisconfigured: null,
194+
}),
195+
).toBe(true);
196+
197+
expect(
198+
deriveDnsVerified({
199+
dnsRegexMatches: false,
200+
vercelMisconfigured: null,
201+
}),
202+
).toBe(false);
203+
});
204+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
export interface DomainVerificationInputs {
2+
/** Did the DNS CNAME record resolve and match a known Vercel target? */
3+
isCnameVerified: boolean;
4+
/** Did the DNS TXT record at the root match `compai-domain-verification=<orgId>`? */
5+
isTxtVerified: boolean;
6+
/** Did the DNS TXT at `_vercel` match the token Vercel gave us? */
7+
isVercelTxtVerified: boolean;
8+
/**
9+
* Whether Vercel requires the `_vercel` TXT record for ownership verification.
10+
* True for cross-account domains where Vercel cannot infer ownership from DNS alone.
11+
*/
12+
requiresVercelTxt: boolean;
13+
/**
14+
* Whether the Vercel integration is configured on the server. False in dev or
15+
* self-hosted setups without VERCEL_TEAM_ID / TRUST_PORTAL_PROJECT_ID — in
16+
* that case we fall back to DNS-only verification.
17+
*/
18+
vercelAvailable: boolean;
19+
/**
20+
* `misconfigured` flag from Vercel's `/v6/domains/{d}/config` endpoint.
21+
* `null` means the call failed — in that case we cannot confirm Vercel's verdict.
22+
*/
23+
vercelMisconfigured: boolean | null;
24+
/**
25+
* Response from triggering Vercel's `/verify` endpoint. Only meaningful for
26+
* cross-account domains. `null` means the call was skipped or errored.
27+
*/
28+
vercelVerifiedAfterTrigger: boolean | null;
29+
}
30+
31+
export interface DomainVerificationResult {
32+
success: boolean;
33+
error?: string;
34+
/**
35+
* True when the failure is due to a transient/indeterminate condition
36+
* (Vercel API unreachable, no verdict available yet). Callers should avoid
37+
* writing `domainVerified=false` on transient failures — doing so can
38+
* de-verify a previously working domain on a temporary outage.
39+
*/
40+
transient?: boolean;
41+
}
42+
43+
export interface DnsVerifiedInputs {
44+
/** Whether the resolved CNAME target matched a known Vercel DNS pattern. */
45+
dnsRegexMatches: boolean;
46+
/**
47+
* `misconfigured` from Vercel's `/v6/domains/{d}/config`. `null` when the
48+
* call failed or Vercel isn't configured on the server.
49+
*/
50+
vercelMisconfigured: boolean | null;
51+
}
52+
53+
/**
54+
* Vercel hosts these domains, so Vercel is the source of truth for whether the
55+
* DNS points at the right project. We accept any configuration method Vercel
56+
* accepts (CNAME for subdomains, A for apex, ALIAS, etc.) — `misconfigured`
57+
* is Vercel's single "works / doesn't work" verdict. Our regex is only a
58+
* fallback for when Vercel's API is unreachable.
59+
*/
60+
export function deriveDnsVerified(inputs: DnsVerifiedInputs): boolean {
61+
if (inputs.vercelMisconfigured !== null) {
62+
return !inputs.vercelMisconfigured;
63+
}
64+
return inputs.dnsRegexMatches;
65+
}
66+
67+
export function decideDomainVerification(
68+
inputs: DomainVerificationInputs,
69+
): DomainVerificationResult {
70+
const {
71+
isCnameVerified,
72+
isTxtVerified,
73+
isVercelTxtVerified,
74+
requiresVercelTxt,
75+
vercelAvailable,
76+
vercelMisconfigured,
77+
vercelVerifiedAfterTrigger,
78+
} = inputs;
79+
80+
// DNS-level checks — user is responsible for these
81+
const dnsOk =
82+
isCnameVerified &&
83+
isTxtVerified &&
84+
(!requiresVercelTxt || isVercelTxtVerified);
85+
86+
if (!dnsOk) {
87+
return {
88+
success: false,
89+
transient: false,
90+
error:
91+
'Some DNS records are not configured correctly. Please check the records marked as unverified above and try again.',
92+
};
93+
}
94+
95+
// Vercel not configured on this server — trust DNS alone (dev/self-host).
96+
if (!vercelAvailable) {
97+
return { success: true };
98+
}
99+
100+
// Vercel-level checks — DNS may look right to us but Vercel must agree
101+
if (vercelMisconfigured === null) {
102+
return {
103+
success: false,
104+
transient: true,
105+
error:
106+
'Could not confirm configuration with Vercel. Please try again in a few minutes.',
107+
};
108+
}
109+
110+
if (vercelMisconfigured === true) {
111+
return {
112+
success: false,
113+
transient: false,
114+
error:
115+
'Vercel reports this domain is still misconfigured. The CNAME value must exactly match the recommended target shown above.',
116+
};
117+
}
118+
119+
if (requiresVercelTxt && vercelVerifiedAfterTrigger !== true) {
120+
// `null` means the /verify call failed (transient); `false` means Vercel
121+
// explicitly said the ownership record is not yet present.
122+
return {
123+
success: false,
124+
transient: vercelVerifiedAfterTrigger === null,
125+
error:
126+
'DNS records verified but Vercel has not yet confirmed domain ownership. Please ensure the _vercel TXT record is correctly configured and try again.',
127+
};
128+
}
129+
130+
return { success: true };
131+
}

apps/api/src/trust-portal/dto/domain-status.dto.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,16 @@ export class DomainStatusResponseDto {
4747

4848
@ApiProperty({
4949
description: 'The recommended CNAME target for this domain from Vercel',
50-
example: 'cname.vercel-dns.com',
50+
example: '3a69a5bb27875189.vercel-dns-016.com',
5151
required: false,
5252
})
5353
cnameTarget?: string;
54+
55+
@ApiProperty({
56+
description:
57+
"Whether Vercel's /v6/domains/{d}/config call reports the domain as misconfigured. Null when Vercel could not be reached.",
58+
required: false,
59+
nullable: true,
60+
})
61+
misconfigured?: boolean | null;
5462
}

0 commit comments

Comments
 (0)