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
8 changes: 7 additions & 1 deletion apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,10 @@ E2E_PARTNER_PASSWORD=

# Veriff (Identity Verification)
VERIFF_API_KEY=
VERIFF_SHARED_SECRET=
VERIFF_SHARED_SECRET=

# Domain Connect (auto-configure DNS for custom domains)
# Generate key: openssl genrsa -out private.pem 2048
# Get public key: openssl rsa -in private.pem -pubout -outform DER | base64 | tr -d '\n'
DOMAIN_CONNECT_PRIVATE_KEY=
DOMAIN_CONNECT_KEY_HOST=_dck1
154 changes: 154 additions & 0 deletions apps/web/app/api/domains/[domain]/domain-connect/apply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { getConfigResponse } from "@/lib/api/domains/get-config-response";
import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
import { getDomainResponse } from "@/lib/api/domains/get-domain-response";
import { DubApiError } from "@/lib/api/errors";
import { withWorkspace } from "@/lib/auth";
import {
DEFAULT_DC_SERVICE_APEX,
DEFAULT_DC_SERVICE_SUBDOMAIN,
} from "@/lib/domain-connect/constants";
import { discoverDomainConnect } from "@/lib/domain-connect/discover";
import { buildSignedApplyUrl } from "@/lib/domain-connect/sign-apply-url";
import { APP_DOMAIN, getApexDomain, getSubdomain } from "@dub/utils";
import { NextResponse } from "next/server";
import * as z from "zod/v4";

const bodySchema = z.object({
recordType: z.enum(["A", "CNAME"]),
returnTo: z.string().max(512).optional(),
});

// POST /api/domains/[domain]/domain-connect/apply
export const POST = withWorkspace(
async ({ req, workspace, params }) => {
const privateKeyPem =
process.env.DOMAIN_CONNECT_PRIVATE_KEY?.trim().replace(/\\n/g, "\n") ||
null;
const keyHost = process.env.DOMAIN_CONNECT_KEY_HOST?.trim() || null;

if (!privateKeyPem || !keyHost) {
throw new DubApiError({
code: "internal_server_error",
message: "Domain Connect signing is not configured.",
});
}

const { slug: domain } = await getDomainOrThrow({
workspace,
domain: params.domain,
dubDomainChecks: true,
});

const body = bodySchema.parse(await req.json());

const [domainJson, configJson] = await Promise.all([
getDomainResponse(domain),
getConfigResponse(domain),
]);

if (domainJson?.error?.code === "not_found" || domainJson?.error) {
throw new DubApiError({
code: "bad_request",
message: "Domain is not available for configuration.",
});
}

if (configJson?.conflicts?.length) {
throw new DubApiError({
code: "bad_request",
message: "Remove conflicting DNS records first, then retry.",
});
}

if (domainJson.verified && !configJson.misconfigured) {
throw new DubApiError({
code: "bad_request",
message: "This domain is already configured correctly.",
});
}

const apex = getApexDomain(`https://${domain}`);
const discovery = await discoverDomainConnect(apex);
if (!discovery) {
throw new DubApiError({
code: "bad_request",
message:
"Auto configure is only available for Vercel or Cloudflare DNS zones.",
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const allowedSyncUXOrigins = [
"https://vercel.com",
"https://dash.cloudflare.com",
];
const syncUXOrigin = new URL(discovery.urlSyncUX).origin;
if (!allowedSyncUXOrigins.includes(syncUXOrigin)) {
throw new DubApiError({
code: "bad_request",
message: "Invalid Domain Connect provider URL.",
});
}

const subdomain = getSubdomain(
domainJson.name?.toLowerCase() ?? domain,
domainJson.apexName?.toLowerCase() ?? apex,
);
const isApex = body.recordType === "A" || !subdomain;
const serviceId = isApex
? DEFAULT_DC_SERVICE_APEX
: DEFAULT_DC_SERVICE_SUBDOMAIN;

const returnPath =
body.returnTo &&
body.returnTo.startsWith(`/${workspace.slug}/`) &&
!body.returnTo.includes("://")
? body.returnTo
: `/${workspace.slug}/settings/domains`;

const redirectUrl = new URL(returnPath, APP_DOMAIN);
redirectUrl.searchParams.set("domain_connect", "callback");
const redirectUri = redirectUrl.toString();

const queryParams: Record<string, string> = {
domain: apex,
groupId: "subdomain",
redirect_uri: redirectUri,
};

if (isApex) {
queryParams.groupId = "apex";
} else {
queryParams.groupId = "subdomain";
queryParams.host = (subdomain ?? "www").toLowerCase();
}

const txtVerification = domainJson.verification?.find(
(x: { type: string }) => x.type === "TXT",
);
if (txtVerification) {
queryParams.groupId = queryParams.groupId + ",verification";
const txtHostFqdn: string = txtVerification.domain?.toLowerCase() ?? "";
const apexSuffix = `.${apex}`;
const txtHost = txtHostFqdn.endsWith(apexSuffix)
? txtHostFqdn.slice(0, -apexSuffix.length)
: txtHostFqdn === apex
? "@"
: txtHostFqdn;
queryParams.txtHost = txtHost;
queryParams.txtValue = txtVerification.value ?? "";
}

const applyUrl = buildSignedApplyUrl({
urlSyncUX: discovery.urlSyncUX,
serviceId,
privateKeyPem,
keyHost,
queryParams,
});

return NextResponse.json({ applyUrl });
},
{
requiredPermissions: ["domains.write"],
},
);
106 changes: 106 additions & 0 deletions apps/web/app/api/domains/[domain]/forward-instructions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
import { getDomainResponse } from "@/lib/api/domains/get-domain-response";
import { DubApiError } from "@/lib/api/errors";
import { withWorkspace } from "@/lib/auth";
import {
DUB_CUSTOM_DOMAIN_A_RECORD,
DUB_CUSTOM_DOMAIN_CNAME,
} from "@/lib/domain-connect/constants";
import { ratelimit } from "@/lib/upstash";
import { sendEmail } from "@dub/email";
import DomainDnsInstructions from "@dub/email/templates/domain-dns-instructions";
import { getApexDomain, getSubdomain } from "@dub/utils";
import { NextResponse } from "next/server";
import * as z from "zod/v4";

const bodySchema = z.object({
email: z.email(),
recordType: z.enum(["A", "CNAME"]),
});

// POST /api/domains/[domain]/forward-instructions
export const POST = withWorkspace(
async ({ req, workspace, params, session }) => {
const { slug: domain } = await getDomainOrThrow({
workspace,
domain: params.domain,
dubDomainChecks: true,
});

const { email, recordType } = bodySchema.parse(await req.json());

const { success } = await ratelimit(10, "1 h").limit(
`forward-dns-instructions:${workspace.id}`,
);
if (!success) {
throw new DubApiError({
code: "rate_limit_exceeded",
message: "Don't DDoS me pls 🥺",
});
}

const records: { type: string; name: string; value: string }[] = [];

const domainJson = await getDomainResponse(domain);

if (domainJson?.error) {
throw new DubApiError({
code: "bad_request",
message: "Could not retrieve DNS records for this domain.",
});
}

const apex = getApexDomain(`https://${domain}`);
const subdomain = getSubdomain(
domainJson.name?.toLowerCase() ?? domain,
domainJson.apexName?.toLowerCase() ?? apex,
);

if (recordType === "A") {
records.push({
type: "A",
name: subdomain ?? "@",
value: DUB_CUSTOM_DOMAIN_A_RECORD,
});
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
records.push({
type: "CNAME",
name: subdomain ?? "www",
value: DUB_CUSTOM_DOMAIN_CNAME,
});
}

const txtVerification = domainJson.verification?.find(
(x: { type: string }) => x.type === "TXT",
);
if (txtVerification) {
const txtHostFqdn: string = txtVerification.domain?.toLowerCase() ?? "";
const apexSuffix = `.${apex}`;
const txtHost = txtHostFqdn.endsWith(apexSuffix)
? txtHostFqdn.slice(0, -apexSuffix.length)
: txtHostFqdn === apex
? "@"
: txtHostFqdn;
const txtValue = txtVerification.value?.trim() ?? "";
if ((txtHost || txtHost === "@") && txtValue) {
records.push({ type: "TXT", name: txtHost, value: txtValue });
}
}

await sendEmail({
subject: `DNS instructions for ${domain}`,
to: email,
react: DomainDnsInstructions({
email,
domain,
records,
senderEmail: session.user.email,
}),
});

return NextResponse.json({ ok: true });
},
{
requiredPermissions: ["domains.read"],
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
29 changes: 20 additions & 9 deletions apps/web/app/api/domains/[domain]/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw";
import { getDomainResponse } from "@/lib/api/domains/get-domain-response";
import { verifyDomain } from "@/lib/api/domains/verify-domain";
import { withWorkspace } from "@/lib/auth";
import { discoverDomainConnectIfEligible } from "@/lib/domain-connect/discover";
import type { DomainConnectDiscovery } from "@/lib/domain-connect/types";
import { DomainVerificationStatusProps } from "@/lib/types";
import { prisma } from "@dub/prisma";
import { getApexDomain } from "@dub/utils";
import { NextResponse } from "next/server";

export const maxDuration = 30;
Expand All @@ -19,6 +22,7 @@ export const GET = withWorkspace(
});

let status: DomainVerificationStatusProps = "Valid Configuration";
const apex = getApexDomain(`https://${domain}`);

const [domainJson, configJson] = await Promise.all([
getDomainResponse(domain),
Expand All @@ -31,12 +35,14 @@ export const GET = withWorkspace(
return NextResponse.json({
status,
response: { configJson, domainJson },
domainConnect: null,
});
} else if (domainJson.error) {
status = "Unknown Error";
return NextResponse.json({
status,
response: { configJson, domainJson },
domainConnect: null,
});
}

Expand All @@ -48,6 +54,7 @@ export const GET = withWorkspace(
return NextResponse.json({
status,
response: { configJson, domainJson },
domainConnect: null,
});
}

Expand All @@ -65,13 +72,18 @@ export const GET = withWorkspace(
status = "Valid Configuration";
}

const domainConnect: DomainConnectDiscovery | null =
await discoverDomainConnectIfEligible(apex, status);

return NextResponse.json({
status,
response: { configJson, domainJson, verificationJson },
domainConnect,
});
}

let prismaResponse: any = null;
let domainConnect: DomainConnectDiscovery | null = null;
if (!configJson.misconfigured) {
prismaResponse = await prisma.domain.update({
where: {
Expand All @@ -84,20 +96,19 @@ export const GET = withWorkspace(
});
} else {
status = "Invalid Configuration";
prismaResponse = await prisma.domain.update({
where: {
slug: domain,
},
data: {
verified: false,
lastChecked: new Date(),
},
});
[prismaResponse, domainConnect] = await Promise.all([
prisma.domain.update({
where: { slug: domain },
data: { verified: false, lastChecked: new Date() },
}),
discoverDomainConnectIfEligible(apex, "Invalid Configuration"),
]);
}

return NextResponse.json({
status,
response: { configJson, domainJson, prismaResponse },
domainConnect,
});
},
{
Expand Down
Loading
Loading