From a230d15ea45b04b2fd0fb6c9800a35b92d200758 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 11:47:52 +0000 Subject: [PATCH] fix: remove custom domains from Vercel on downgrade to free The customerSubscriptionDeleted webhook cleared page.customDomain in the DB but left the domain attached to the Vercel project, so it kept routing to the status page after downgrade. Collect the custom domains of all workspace pages (including the extra pages the downgrade deletes) inside the transaction and release them on Vercel after commit, best-effort so a Vercel error doesn't fail the webhook. Moves the Vercel domain helpers from the page router into src/lib/vercel.ts so both routers share them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yVQKb8JAxQP9gpoBibsUb --- packages/api/src/lib/vercel.ts | 81 +++++++++++++++++++++++ packages/api/src/router/page.ts | 80 +--------------------- packages/api/src/router/stripe/webhook.ts | 20 +++++- 3 files changed, 101 insertions(+), 80 deletions(-) create mode 100644 packages/api/src/lib/vercel.ts diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts new file mode 100644 index 000000000..76a694b94 --- /dev/null +++ b/packages/api/src/lib/vercel.ts @@ -0,0 +1,81 @@ +import { TRPCError } from "@trpc/server"; + +import { env } from "../env"; + +// Vercel domain helpers — transport-layer external integrations that +// don't belong in the service layer. +export async function addDomainToVercel(domain: string) { + const response = await fetch( + `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, + { + body: JSON.stringify({ name: domain }), + headers: { + Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json", + }, + method: "POST", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + const code = error?.error?.code; + console.error("Failed to add domain to Vercel:", { domain, error }); + throw toDomainError(domain, code); + } + + 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) { + case "domain_already_in_use": + return new TRPCError({ + code: "CONFLICT", + message: `The domain '${domain}' is already in use by another status page. Remove it there first or contact support.`, + }); + case "invalid_domain": + case "not_found": + return new TRPCError({ + code: "BAD_REQUEST", + message: `The domain '${domain}' is invalid.`, + }); + case "forbidden": + case "domain_taken": + return new TRPCError({ + code: "FORBIDDEN", + message: `The domain '${domain}' belongs to another team on our hosting provider. Contact support if you own it.`, + }); + default: + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to add custom domain. Please try again. If it continues, contact support.", + }); + } +} + +export async function removeDomainFromVercel(domain: string) { + const response = await fetch( + `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, + { + headers: { + Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, + }, + method: "DELETE", + }, + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.error("Failed to remove domain from Vercel:", { domain, error }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to remove custom domain. Please try again. If it continues, contact support.", + }); + } + + return response.json(); +} diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index 9dff33a8c..11cbdf3e1 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -31,88 +31,10 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { env } from "../env"; +import { addDomainToVercel, removeDomainFromVercel } from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -// Vercel domain helpers — transport-layer external integrations that -// don't belong in the service layer. -async function addDomainToVercel(domain: string) { - const response = await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, - { - body: JSON.stringify({ name: domain }), - headers: { - Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, - "Content-Type": "application/json", - }, - method: "POST", - }, - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - const code = error?.error?.code; - console.error("Failed to add domain to Vercel:", { domain, error }); - throw toDomainError(domain, code); - } - - 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) { - case "domain_already_in_use": - return new TRPCError({ - code: "CONFLICT", - message: `The domain '${domain}' is already in use by another status page. Remove it there first or contact support.`, - }); - case "invalid_domain": - case "not_found": - return new TRPCError({ - code: "BAD_REQUEST", - message: `The domain '${domain}' is invalid.`, - }); - case "forbidden": - case "domain_taken": - return new TRPCError({ - code: "FORBIDDEN", - message: `The domain '${domain}' belongs to another team on our hosting provider. Contact support if you own it.`, - }); - default: - return new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to add custom domain. Please try again. If it continues, contact support.", - }); - } -} - -async function removeDomainFromVercel(domain: string) { - const response = await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${env.TEAM_ID_VERCEL}`, - { - headers: { - Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, - }, - method: "DELETE", - }, - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({})); - console.error("Failed to remove domain from Vercel:", { domain, error }); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to remove custom domain. Please try again. If it continues, contact support.", - }); - } - - return response.json(); -} - export const pageRouter = createTRPCRouter({ create: protectedProcedure .meta({ track: Events.CreatePage, trackProps: ["slug"] }) diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 192f5e5cc..c928b8eeb 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -15,6 +15,7 @@ import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; +import { removeDomainFromVercel } from "../../lib/vercel"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { stripe } from "./shared"; import { buildLimitsFromSubscription } from "./utils"; @@ -223,6 +224,8 @@ export const webhookRouter = createTRPCRouter({ return; } + let customDomains: string[] = []; + const _workspace = await opts.ctx.db.transaction(async (tx) => { const _workspace = await tx .update(workspace) @@ -266,11 +269,15 @@ export const webhookRouter = createTRPCRouter({ } const statusPages = await tx - .select({ id: page.id }) + .select({ id: page.id, customDomain: page.customDomain }) .from(page) .where(eq(page.workspaceId, workspaceId)) .orderBy(asc(page.createdAt)); + customDomains = statusPages + .map((p) => p.customDomain) + .filter((domain) => domain !== ""); + for (const p of statusPages.slice(1)) { await tx.delete(page).where(eq(page.id, p.id)).run(); } @@ -331,6 +338,17 @@ export const webhookRouter = createTRPCRouter({ }); } + // Free plan has no custom-domain feature — release the domains on Vercel + // so they stop routing to the status page. Best-effort after commit: the + // downgrade must not fail (and get retried by Stripe) on a Vercel error. + for (const domain of customDomains) { + try { + await removeDomainFromVercel(domain); + } catch (_e) { + // already logged inside removeDomainFromVercel + } + } + const workspaceId = _workspace[0].id; const customer = await stripe.customers.retrieve(customerId); -- 2.51.2