diff --git a/apps/dashboard/src/components/domains/domain-configuration.tsx b/apps/dashboard/src/components/domains/domain-configuration.tsx
index 5d9ac4bc..2b27c88d 100644
--- a/apps/dashboard/src/components/domains/domain-configuration.tsx
+++ b/apps/dashboard/src/components/domains/domain-configuration.tsx
@@ -8,6 +8,7 @@ import {
TabsTrigger,
} from "@openstatus/ui/components/ui/tabs";
import { cn } from "@openstatus/ui/lib/utils";
+import { useEffect, useRef } from "react";
import {
StepCard,
@@ -48,7 +49,17 @@ const A_RECORD_VALUE =
process.env.NEXT_PUBLIC_VERCEL_PROJECT_DNS_A || "76.76.21.21";
export default function DomainConfiguration({ domain }: { domain: string }) {
- const { status, domainJson, steps, isLoading } = useDomainStatus(domain);
+ const { status, domainJson, steps, isLoading, issueCertificateMutation } =
+ useDomainStatus(domain);
+ const certificateRequested = useRef(false);
+
+ // Vercel's own retry can leave the order idle for a long time; nudge it once per visit.
+ useEffect(() => {
+ if (status !== "Generating SSL Certificate") return;
+ if (certificateRequested.current) return;
+ certificateRequested.current = true;
+ issueCertificateMutation.mutate({ domain });
+ }, [status, domain, issueCertificateMutation]);
if (isLoading && !domainJson)
return (
@@ -215,6 +226,22 @@ export default function DomainConfiguration({ domain }: { domain: string }) {
+ {/* Step 3: SSL Certificate */}
+
+
+
+ Generate SSL certificate
+ Done
+
+
+
+ Once DNS is configured, we request an SSL certificate for{" "}
+ {domain}. This usually takes a few
+ minutes; the status updates automatically.
+
+
+
+
{status !== "Valid Configuration" ? (
) : null}
diff --git a/apps/dashboard/src/components/domains/domain-status-icon.tsx b/apps/dashboard/src/components/domains/domain-status-icon.tsx
index 9aa17ca9..3a8e7029 100644
--- a/apps/dashboard/src/components/domains/domain-status-icon.tsx
+++ b/apps/dashboard/src/components/domains/domain-status-icon.tsx
@@ -19,7 +19,8 @@ export function DomainStatusIcon({
- ) : status === "Pending Verification" ? (
+ ) : status === "Pending Verification" ||
+ status === "Generating SSL Certificate" ? (
diff --git a/apps/dashboard/src/components/domains/use-domain-status.ts b/apps/dashboard/src/components/domains/use-domain-status.ts
index ef7959e3..78266f2b 100644
--- a/apps/dashboard/src/components/domains/use-domain-status.ts
+++ b/apps/dashboard/src/components/domains/use-domain-status.ts
@@ -1,5 +1,5 @@
import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import type { StepCardVariant } from "@/components/forms/step-card";
@@ -16,6 +16,7 @@ export function useDomainStatus(domain?: string) {
const {
data: configJson,
refetch: refetchConfig,
+ isError: isConfigError,
isLoading: isLoadingConfig,
isRefetching: isRefetchingConfig,
} = useQuery(trpc.domain.getConfigResponse.queryOptions({ domain }));
@@ -31,11 +32,39 @@ export function useDomainStatus(domain?: string) {
),
);
+ const dnsReady =
+ !!domainJson?.verified && configJson?.misconfigured === false;
+ const {
+ data: certificateJson,
+ refetch: refetchCertificate,
+ isError: isCertificateError,
+ isLoading: isLoadingCertificate,
+ isRefetching: isRefetchingCertificate,
+ } = useQuery(
+ trpc.domain.getCertificateStatus.queryOptions(
+ { domain },
+ { enabled: dnsReady },
+ ),
+ );
+
+ const issueCertificateMutation = useMutation(
+ trpc.domain.issueCertificate.mutationOptions({
+ onSuccess: () => refetchCertificate(),
+ }),
+ );
+
const refreshAll = useCallback(() => {
refetchDomain();
refetchConfig();
refetchVerification();
- }, [refetchDomain, refetchConfig, refetchVerification]);
+ if (dnsReady) refetchCertificate();
+ }, [
+ refetchDomain,
+ refetchConfig,
+ refetchVerification,
+ refetchCertificate,
+ dnsReady,
+ ]);
let status: DomainVerificationStatusProps = "Valid Configuration";
@@ -55,8 +84,13 @@ export function useDomainStatus(domain?: string) {
if (verificationJson?.verified) {
status = "Valid Configuration";
}
- } else if (configJson?.misconfigured) {
+ } else if (configJson?.misconfigured || isConfigError) {
status = "Invalid Configuration";
+ } else if (
+ isCertificateError ||
+ (certificateJson && !certificateJson.ready)
+ ) {
+ status = "Generating SSL Certificate";
} else {
status = "Valid Configuration";
}
@@ -65,17 +99,22 @@ export function useDomainStatus(domain?: string) {
isLoadingDomain ||
isLoadingConfig ||
isLoadingVerification ||
+ (dnsReady && isLoadingCertificate) ||
isRefetchingDomain ||
isRefetchingConfig ||
- isRefetchingVerification;
+ isRefetchingVerification ||
+ isRefetchingCertificate;
const steps = {
dns:
- status === "Valid Configuration" || status === "Pending Verification"
+ status === "Valid Configuration" ||
+ status === "Pending Verification" ||
+ status === "Generating SSL Certificate"
? "completed"
: "active",
verification:
- status === "Valid Configuration"
+ status === "Valid Configuration" ||
+ status === "Generating SSL Certificate"
? "completed"
: status === "Pending Verification"
? "active"
@@ -83,7 +122,12 @@ export function useDomainStatus(domain?: string) {
status === "Invalid Configuration"
? "completed"
: "upcoming",
- ready: status === "Valid Configuration" ? "completed" : "upcoming",
+ certificate:
+ status === "Valid Configuration"
+ ? "completed"
+ : status === "Generating SSL Certificate"
+ ? "active"
+ : "upcoming",
} satisfies Record;
return {
@@ -91,6 +135,7 @@ export function useDomainStatus(domain?: string) {
domainJson,
steps,
refresh: refreshAll,
+ issueCertificateMutation,
isLoading,
};
}
diff --git a/packages/api/src/lib/tls.ts b/packages/api/src/lib/tls.ts
new file mode 100644
index 00000000..9e1f9ac1
--- /dev/null
+++ b/packages/api/src/lib/tls.ts
@@ -0,0 +1,36 @@
+import { lookup } from "node:dns/promises";
+import { connect } from "node:tls";
+
+import { assertSafeUrlSync } from "@openstatus/utils";
+
+const PROBE_TIMEOUT_MS = 5_000;
+
+/** Whether `domain:443` serves a certificate that validates for `domain`. */
+export async function hasTrustedCertificate(domain: string): Promise {
+ let address: string;
+ try {
+ // Custom domains on Vercel are IPv4-only (A/CNAME records).
+ ({ address } = await lookup(domain, { family: 4 }));
+ assertSafeUrlSync(`https://${address}`);
+ } catch {
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ // Connect to the checked address so a second lookup can't be rebound to
+ // an internal host; `servername` still drives SNI and hostname validation.
+ const socket = connect({
+ host: address,
+ port: 443,
+ servername: domain,
+ timeout: PROBE_TIMEOUT_MS,
+ });
+ const done = (trusted: boolean) => {
+ socket.destroy();
+ resolve(trusted);
+ };
+ socket.once("secureConnect", () => done(true));
+ socket.once("timeout", () => done(false));
+ socket.once("error", () => done(false));
+ });
+}
diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts
index 30a37385..2c4b0b59 100644
--- a/packages/api/src/lib/vercel.ts
+++ b/packages/api/src/lib/vercel.ts
@@ -1,8 +1,10 @@
import { and, db as defaultDb, ne, sql } from "@openstatus/db";
import { page } from "@openstatus/db/src/schema";
import { TRPCError } from "@trpc/server";
+import { z } from "zod";
import { env } from "../env";
+import { hasTrustedCertificate } from "./tls";
// Vercel domain helpers — transport-layer external integrations that
// don't belong in the service layer.
@@ -49,6 +51,68 @@ export async function addDomainToVercel(domain: string) {
return response.json();
}
+export const domainConfigResponseSchema = z.object({
+ configuredBy: z
+ .union([z.literal("CNAME"), z.literal("A"), z.literal("http")])
+ .optional()
+ .nullable(),
+ acceptedChallenges: z
+ .array(z.union([z.literal("dns-01"), z.literal("http-01")]))
+ .optional()
+ .nullable(),
+ misconfigured: z.boolean().prefault(true).optional(),
+});
+
+export async function fetchDomainConfig(domain: string) {
+ const data = await vercelFetch(
+ `/v6/domains/${encodeURIComponent(domain)}/config?teamId=${env.TEAM_ID_VERCEL}`,
+ );
+ if (!data.ok) {
+ const error = await data.json().catch(() => ({}));
+ console.error("Failed to fetch domain config from Vercel:", {
+ domain,
+ error,
+ });
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "Failed to check the domain configuration. Please try again later.",
+ });
+ }
+ const json = await data.json();
+ return domainConfigResponseSchema.parse(json);
+}
+
+// Only probe hosts Vercel confirms point at us, so the TLS handshake never
+// targets an arbitrary customer-controlled address.
+export async function getCertificateReadiness(domain: string) {
+ const config = await fetchDomainConfig(domain);
+ if (config.misconfigured !== false)
+ return { configured: false, ready: false };
+ return { configured: true, ready: await hasTrustedCertificate(domain) };
+}
+
+// Vercel retries certificate orders on its own backoff, which can leave a
+// correctly configured domain on "Generating SSL" for a long time.
+export async function issueCertificateOnVercel(domain: string) {
+ const response = await vercelFetch(`/v8/certs?teamId=${env.TEAM_ID_VERCEL}`, {
+ body: JSON.stringify({ cns: [domain] }),
+ method: "POST",
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ console.error("Failed to issue certificate on Vercel:", { domain, error });
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "Failed to issue the SSL certificate. Please try again later. If it continues, contact support.",
+ });
+ }
+
+ return response.json();
+}
+
// Vercel messages leak internal project details, so map known codes to our own copy.
function toDomainError(domain: string, code?: string): TRPCError {
switch (code) {
diff --git a/packages/api/src/router/domain.test.ts b/packages/api/src/router/domain.test.ts
index 12f56634..10ee4cb3 100644
--- a/packages/api/src/router/domain.test.ts
+++ b/packages/api/src/router/domain.test.ts
@@ -49,6 +49,8 @@ for (const procedure of [
"getDomainResponse",
"getConfigResponse",
"verifyDomain",
+ "getCertificateStatus",
+ "issueCertificate",
] as const) {
test(`domain.${procedure} rejects another workspace's domain`, async () => {
const error = await getCaller()
@@ -73,6 +75,8 @@ for (const procedure of [
"getDomainResponse",
"getConfigResponse",
"verifyDomain",
+ "getCertificateStatus",
+ "issueCertificate",
] as const) {
test(`domain.${procedure} cannot escape the domains path with an owned traversal domain`, async () => {
const traversal = `evil-${ownWorkspaceId}.example/../../../../v2/user#`;
@@ -125,6 +129,83 @@ test("domain.getDomainResponse reaches Vercel for the workspace's own domain", a
}
});
+async function withOwnDomain(
+ vercelResponses: Record,
+ run: (domain: string, requests: Request[]) => Promise,
+) {
+ // `.invalid` never resolves, so the TLS probe reports no trusted certificate.
+ const domain = `ssl-${ownWorkspaceId}-${crypto.randomUUID()}.example.invalid`;
+ const own = await createPage(ownWorkspaceId, { customDomain: domain });
+ const original = globalThis.fetch;
+ const requests: Request[] = [];
+ globalThis.fetch = (input, init) => {
+ const request = new Request(input, init);
+ requests.push(request);
+ const prefix = new URL(request.url).pathname.split("/")[2];
+ return Promise.resolve(Response.json(vercelResponses[prefix] ?? {}));
+ };
+ try {
+ await run(domain, requests);
+ } finally {
+ globalThis.fetch = original;
+ await db.delete(page).where(eq(page.id, own.id));
+ }
+}
+
+test("domain.issueCertificate orders a cert once DNS points at Vercel", async () => {
+ await withOwnDomain(
+ { domains: { misconfigured: false }, certs: { id: "cert_1" } },
+ async (domain, requests) => {
+ const result = await getCaller().domain.issueCertificate({ domain });
+ expect(result).toEqual({ issued: true });
+ const order = requests.find((r) => r.url.includes("/v8/certs"));
+ expect(order?.method).toBe("POST");
+ expect(await order?.json()).toEqual({ cns: [domain] });
+ },
+ );
+});
+
+test("domain.issueCertificate skips the order while DNS is misconfigured", async () => {
+ await withOwnDomain(
+ { domains: { misconfigured: true } },
+ async (domain, requests) => {
+ const result = await getCaller().domain.issueCertificate({ domain });
+ expect(result).toEqual({ issued: false });
+ expect(requests.some((r) => r.url.includes("/v8/certs"))).toBe(false);
+ },
+ );
+});
+
+test("domain.getCertificateStatus is not ready without a trusted certificate", async () => {
+ await withOwnDomain({ domains: { misconfigured: false } }, async (domain) => {
+ const result = await getCaller().domain.getCertificateStatus({ domain });
+ expect(result).toEqual({ ready: false });
+ });
+});
+
+test("domain.getCertificateStatus surfaces a Vercel config failure instead of reading it as misconfigured", async () => {
+ await withOwnDomain({}, async (domain) => {
+ const original = globalThis.fetch;
+ globalThis.fetch = () =>
+ Promise.resolve(
+ Response.json(
+ { error: { code: "internal", message: "boom" } },
+ { status: 500 },
+ ),
+ );
+ try {
+ const error = await getCaller()
+ .domain.getCertificateStatus({ domain })
+ .catch((e) => e);
+ expect(error).toBeInstanceOf(TRPCError);
+ expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR");
+ expect((error as TRPCError).message).not.toContain("boom");
+ } finally {
+ globalThis.fetch = original;
+ }
+ });
+});
+
for (const path of [
"/v9/projects/p/domains/../../../../v2/user",
"/v9/projects/p/domains/%2e%2e/%2e%2e/x",
diff --git a/packages/api/src/router/domain.ts b/packages/api/src/router/domain.ts
index a127d7c7..e2a8f3c6 100644
--- a/packages/api/src/router/domain.ts
+++ b/packages/api/src/router/domain.ts
@@ -3,22 +3,16 @@ import { assertCustomDomainInWorkspace } from "@openstatus/services/page";
import { z } from "zod";
import { env } from "../env";
-import { vercelFetch } from "../lib/vercel";
+import {
+ type domainConfigResponseSchema,
+ fetchDomainConfig,
+ getCertificateReadiness,
+ issueCertificateOnVercel,
+ vercelFetch,
+} from "../lib/vercel";
import { toServiceCtx, toTRPCError } from "../service-adapter";
import { createTRPCRouter, protectedProcedure } from "../trpc";
-export const domainConfigResponseSchema = z.object({
- configuredBy: z
- .union([z.literal("CNAME"), z.literal("A"), z.literal("http")])
- .optional()
- .nullable(),
- acceptedChallenges: z
- .array(z.union([z.literal("dns-01"), z.literal("http-01")]))
- .optional()
- .nullable(),
- misconfigured: z.boolean().prefault(true).optional(),
-});
-
export const domainResponseSchema = z.object({
name: z.string().optional(),
apexName: z.string().optional(),
@@ -51,6 +45,7 @@ export type DomainVerificationStatusProps =
| "Valid Configuration"
| "Invalid Configuration"
| "Pending Verification"
+ | "Generating SSL Certificate"
| "Domain Not Found"
| "Unknown Error";
@@ -101,12 +96,33 @@ export const domainRouter = createTRPCRouter({
return null;
}
await assertOwned(opts.ctx, opts.input.domain);
- const data = await vercelFetch(
- `/v6/domains/${encodeURIComponent(opts.input.domain)}/config?teamId=${env.TEAM_ID_VERCEL}`,
+ return fetchDomainConfig(opts.input.domain);
+ }),
+ getCertificateStatus: protectedProcedure
+ .input(domainInput)
+ .query(async (opts) => {
+ if (!opts.input.domain) {
+ return null;
+ }
+ await assertOwned(opts.ctx, opts.input.domain);
+ const { ready } = await getCertificateReadiness(opts.input.domain);
+ return { ready };
+ }),
+ issueCertificate: protectedProcedure
+ .input(domainInput)
+ .mutation(async (opts) => {
+ if (!opts.input.domain) {
+ return { issued: false };
+ }
+ await assertOwned(opts.ctx, opts.input.domain);
+ const { configured, ready } = await getCertificateReadiness(
+ opts.input.domain,
);
- const json = await data.json();
- const result = domainConfigResponseSchema.parse(json);
- return result;
+ if (!configured || ready) {
+ return { issued: false };
+ }
+ await issueCertificateOnVercel(opts.input.domain);
+ return { issued: true };
}),
verifyDomain: protectedProcedure.input(domainInput).query(async (opts) => {
if (!opts.input.domain) {