Skip to content
Open
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
61 changes: 58 additions & 3 deletions apps/web/actions/organization/domain-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
import { parse } from "tldts";

const VERCEL_CNAME_TARGET = "cname.vercel-dns.com";
const VERCEL_A_RECORD = "76.76.21.21";

export const isSubdomain = (raw: string): boolean => {
const input =
raw
.trim()
.replace(/^https?:\/\//i, "")
.split("/")[0] ?? "";
if (!input) return false;
const host = (input.replace(/\.$/, "").split(":")[0] || "").toLowerCase();
const { subdomain } = parse(host);
return Boolean(subdomain);
};

export const getConfigResponse = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v6/domains/${domain.toLowerCase()}/config?teamId=${
Expand Down Expand Up @@ -67,6 +84,22 @@ export const addDomain = async (domain: string) => {
return response;
};

export const removeDomain = async (domain: string) => {
const response = await fetch(
`https://api.vercel.com/v9/projects/${
process.env.VERCEL_PROJECT_ID
}/domains/${domain.toLowerCase()}?teamId=${process.env.VERCEL_TEAM_ID}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`,
},
},
).then((res) => res.json());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 DELETE response requires JSON body

If Vercel returns an empty or non-JSON body for this DELETE request, res.json() rejects before removeOrganizationDomain clears the local domain fields, leaving the organization with stale custom-domain state even when the HTTP request completed.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/organization/domain-utils.ts
Line: 98

Comment:
**DELETE response requires JSON body**

If Vercel returns an empty or non-JSON body for this DELETE request, `res.json()` rejects before `removeOrganizationDomain` clears the local domain fields, leaving the organization with stale custom-domain state even when the HTTP request completed.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


return response;
};

export const getRequiredConfig = async (domain: string) => {
// First try to get the records directly
try {
Expand Down Expand Up @@ -162,9 +195,28 @@ export const checkDomainStatus = async (domain: string) => {
verified = verificationJson?.verified;
}

// Get the current and required A records
const currentAValues = configJson.aValues || [];
const requiredAValue = requiredConfigJson.aValues?.[0];
const subdomain = isSubdomain(domain);

// Vercel's recommendations are the source of truth, but the config
// endpoint can omit them (e.g. for domains not using Vercel DNS). Fall
// back to the well-known Vercel targets so the setup UI always has a
// record to display instead of rendering empty.
let recommendedCNAME: Array<{ rank: number; value: string }> =
configJson.recommendedCNAME || [];
let recommendedIPv4: Array<{ rank: number; value: string[] | string }> =
configJson.recommendedIPv4 || [];

if (subdomain && recommendedCNAME.length === 0) {
recommendedCNAME = [{ rank: 0, value: VERCEL_CNAME_TARGET }];
}
if (!subdomain && recommendedIPv4.length === 0) {
recommendedIPv4 = [{ rank: 0, value: [VERCEL_A_RECORD] }];
}

const requiredAValue =
requiredConfigJson.aValues?.[0] ??
(!subdomain ? VERCEL_A_RECORD : undefined);

return {
verified,
Expand All @@ -173,10 +225,13 @@ export const checkDomainStatus = async (domain: string) => {
verification: domainJson?.verification || [],
currentAValues,
requiredAValue,
recommendedCNAME,
recommendedIPv4,
},
status: domainJson,
};
} catch (_error) {
} catch (error) {
console.error("checkDomainStatus failed", { domain, error });
return {
verified: false,
error: "Failed to check domain status",
Expand Down
15 changes: 2 additions & 13 deletions apps/web/actions/organization/remove-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Organisation } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requireOrganizationSettingsManager } from "./authorization";
import { removeDomain } from "./domain-utils";

export async function removeOrganizationDomain(
organizationId: Organisation.OrganisationId,
Expand All @@ -28,19 +29,7 @@ export async function removeOrganizationDomain(

try {
if (organization.customDomain) {
await fetch(
`https://api.vercel.com/v9/projects/${
process.env.VERCEL_PROJECT_ID
}/domains/${organization.customDomain.toLowerCase()}?teamId=${
process.env.VERCEL_TEAM_ID
}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`,
},
},
);
await removeDomain(organization.customDomain);
}

await db()
Expand Down
10 changes: 6 additions & 4 deletions apps/web/actions/organization/update-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export async function updateDomain(
throw new Error("User is not subscribed");
}

const normalizedDomain = domain.trim().toLowerCase();

const [organization] = await db()
.select()
.from(organizations)
Expand All @@ -37,15 +39,15 @@ export async function updateDomain(
const existingDomain = await db()
.select()
.from(organizations)
.where(eq(organizations.customDomain, domain))
.where(eq(organizations.customDomain, normalizedDomain))
.limit(1);

if (existingDomain.length > 0 && existingDomain[0]?.id !== organizationId) {
throw new Error("This domain is already being used.");
}

try {
const addDomainResponse = await addDomain(domain);
const addDomainResponse = await addDomain(normalizedDomain);

if (addDomainResponse.error) {
throw new Error(addDomainResponse.error.message);
Expand All @@ -54,12 +56,12 @@ export async function updateDomain(
await db()
.update(organizations)
.set({
customDomain: domain,
customDomain: normalizedDomain,
domainVerified: null,
})
.where(eq(organizations.id, organizationId));

const status = await checkDomainStatus(domain);
const status = await checkDomainStatus(normalizedDomain);

if (status.verified) {
await db()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ const VerifyStep = ({
hasRecommendedCNAME && !cnameConfigured && isSubdomain(domain);
const showTXTRecord = hasTXTVerification && !isVerified;

const showFallbackRecords =
!showTXTRecord &&
!showARecord &&
!showCNAMERecord &&
!aRecordConfigured &&
!cnameConfigured;
const fallbackIsSubdomain = isSubdomain(domain);

const handleCopy = async (text: string, fieldId: string) => {
try {
await navigator.clipboard.writeText(text);
Expand Down Expand Up @@ -146,6 +154,13 @@ const VerifyStep = ({
<div className="flex justify-center items-center w-full h-20">
<LoadingSpinner size={36} />
</div>
) : !isVerified && !domainConfig ? (
<div className="px-4 py-3 text-center rounded-lg border border-gray-4 bg-gray-2">
<p className="text-sm text-gray-11">
We couldn't load the DNS configuration for this domain. Use Check
Status to try again.
</p>
</div>
) : (
!isVerified &&
domainConfig && (
Expand Down Expand Up @@ -432,6 +447,73 @@ const VerifyStep = ({
</div>
</div>
)}

{/* Fallback when Vercel returned no recommendations */}
{showFallbackRecords && (
<div className="overflow-hidden rounded-lg border border-gray-4">
<div className="px-4 py-3 border-b bg-gray-2 border-gray-4">
<p className="font-medium text-md text-gray-12">
{fallbackIsSubdomain
? "CNAME Record Configuration"
: "A Record Configuration"}
</p>
<p className="mt-1 text-sm text-gray-10">
Add this record to your domain:
</p>
</div>
<div className="px-4 py-3">
<dl className="grid gap-4">
<div className="grid grid-cols-[100px,1fr] items-center">
<dt className="text-sm font-medium text-gray-12">Type</dt>
<dd className="text-sm text-gray-10">
{fallbackIsSubdomain ? "CNAME" : "A"}
</dd>
</div>
<div className="grid grid-cols-[100px,1fr] items-center">
<dt className="text-sm font-medium text-gray-12">Name</dt>
<dd className="text-sm text-gray-10">
<code className="px-2 py-1 text-xs rounded bg-gray-4">
{fallbackIsSubdomain ? domain.split(".")[0] : "@"}
</code>
</dd>
</div>
<div className="grid grid-cols-[100px,1fr] items-center">
<dt className="text-sm font-medium text-gray-12">
Value
</dt>
<dd className="text-sm text-gray-10">
<div className="flex items-center justify-between gap-1.5 bg-gray-3 px-2 py-1 rounded-lg flex-1 min-w-0 border border-gray-4">
<code className="text-xs text-gray-10">
{fallbackIsSubdomain
? "cname.vercel-dns.com"
: "76.76.21.21"}
</code>
<button
type="button"
onClick={() =>
handleCopy(
fallbackIsSubdomain
? "cname.vercel-dns.com"
: "76.76.21.21",
"fallback-record",
)
}
className="p-1 rounded-md transition-colors hover:bg-gray-1 shrink-0"
title="Copy to clipboard"
>
{copiedField === "fallback-record" ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5 text-gray-10" />
)}
</button>
</div>
</dd>
</div>
</dl>
</div>
</div>
)}
</div>
)
)}
Expand Down
Loading