Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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