From b60bdf8c9b005b31a504bc0141e928219595861f Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 6 Oct 2026 09:17:41 +0200 Subject: [PATCH] fix: vercel reconcile custom domain --- .../routes/rpc/handlers/status-page/index.ts | 60 ++-- .../api/internal/domains/reconcile/route.ts | 33 ++ apps/web/vercel.json | 4 + packages/api/src/lib/vercel.ts | 131 +------- packages/api/src/router/domain.test.ts | 14 - packages/api/src/router/page.ts | 83 +---- packages/api/src/router/stripe/webhook.ts | 4 +- packages/api/src/router/user.ts | 4 +- packages/services/src/context.ts | 3 + .../src/page/__tests__/domain-sync.test.ts | 205 +++++++++++++ .../page/__tests__/set-custom-domain.test.ts | 229 ++++++++++++++ packages/services/src/page/delete.ts | 26 +- packages/services/src/page/domain-sync.ts | 286 ++++++++++++++++++ packages/services/src/page/index.ts | 14 + .../services/src/page/set-custom-domain.ts | 54 ++++ packages/services/src/user/delete.ts | 2 +- packages/services/src/workspace/downgrade.ts | 6 +- 17 files changed, 911 insertions(+), 247 deletions(-) create mode 100644 apps/web/src/app/api/internal/domains/reconcile/route.ts create mode 100644 packages/services/src/page/__tests__/domain-sync.test.ts create mode 100644 packages/services/src/page/__tests__/set-custom-domain.test.ts create mode 100644 packages/services/src/page/domain-sync.ts create mode 100644 packages/services/src/page/set-custom-domain.ts diff --git a/apps/server/src/routes/rpc/handlers/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts index 06d77cc5a..31ef1f62f 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -50,7 +50,7 @@ import { type StatusPageContent, listPages, updatePageAppearance, - updatePageCustomDomain, + setPageCustomDomain, updatePageCustomTheme, updatePageGeneral, updatePageLinks, @@ -494,18 +494,6 @@ export const statusPageServiceImpl: ServiceImpl = { // ========================================================================== // Page CRUD // ========================================================================== - // - // Known gap (predates the services migration): both `createStatusPage` - // and `updateStatusPage` accept and persist `customDomain`, but - // neither calls the Vercel add/remove API the way the tRPC - // `updateCustomDomain` procedure does. Clients setting a custom domain - // via gRPC will get a db row that says the domain is set, but routing - // won't actually work until a tRPC/dashboard round-trip picks up the - // diff. The fix is to lift the Vercel sync (`addDomainToVercel` / - // `removeDomainFromVercel`) into a shared transport-layer helper the - // Connect handlers can reuse, kept out of the service layer. Tracked - // as a follow-up; not landing here to avoid widening the behavioural - // blast radius of the migration PR on external API consumers. async createStatusPage(req, ctx) { try { @@ -622,7 +610,7 @@ export const statusPageServiceImpl: ServiceImpl = { title: req.title, description: req.description ?? "", slug: req.slug, - customDomain, + customDomain: "", icon, forceTheme, accessType, @@ -655,7 +643,33 @@ export const statusPageServiceImpl: ServiceImpl = { throw err; }); - return { statusPage: dbPageToProto(serviceToConverterPage(created)) }; + // Set after create so the row never claims a domain Vercel doesn't route. + if (customDomain) { + try { + await setPageCustomDomain({ + ctx: sCtx, + input: { id: created.id, customDomain }, + }); + } catch (err) { + await deletePage({ + ctx: sCtx, + input: { id: created.id }, + releaseDomain: false, + }).catch((cleanupErr) => + console.error("Failed to roll back status page create:", { + pageId: created.id, + error: cleanupErr, + }), + ); + throw err; + } + } + + return { + statusPage: dbPageToProto( + serviceToConverterPage({ ...created, customDomain }), + ), + }; } catch (err) { toConnectError(err); } @@ -864,6 +878,15 @@ export const statusPageServiceImpl: ServiceImpl = { customThemeForUpdate = validateProtoCustomTheme(req.customTheme); } + // Outside the transaction below: Vercel calls must not hold the libSQL + // writer. First, so an attach failure aborts before any other write. + if (customDomainForUpdate !== undefined) { + await setPageCustomDomain({ + ctx: sCtx, + input: { id: pageId, customDomain: customDomainForUpdate }, + }); + } + // Wrap all per-section updates in a single transaction so partial // failures don't leave the page in a half-updated state. Each // per-section service call's internal `withTransaction` detects @@ -952,13 +975,6 @@ export const statusPageServiceImpl: ServiceImpl = { }); } - if (customDomainForUpdate !== undefined) { - await updatePageCustomDomain({ - ctx: txCtx, - input: { id: pageId, customDomain: customDomainForUpdate }, - }); - } - if (customThemeForUpdate !== undefined) { await updatePageCustomTheme({ ctx: txCtx, diff --git a/apps/web/src/app/api/internal/domains/reconcile/route.ts b/apps/web/src/app/api/internal/domains/reconcile/route.ts new file mode 100644 index 000000000..495a5a402 --- /dev/null +++ b/apps/web/src/app/api/internal/domains/reconcile/route.ts @@ -0,0 +1,33 @@ +import { db } from "@openstatus/db/src/db"; +import { + reconcileProjectDomains, + vercelConfigFromEnv, +} from "@openstatus/services/page"; +import { captureMessage } from "@sentry/nextjs"; +import type { NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return new Response("Unauthorized", { status: 401 }); + } + + const config = vercelConfigFromEnv(); + if (!config) return Response.json({ skipped: true }); + + const result = await reconcileProjectDomains({ + db, + config, + apply: process.env.DOMAIN_RECONCILE_APPLY === "1", + }); + + if (result.orphans.length > 0 || result.missing.length > 0) { + console.warn("Custom domain drift between Vercel and page table:", result); + captureMessage("Custom domain drift between Vercel and page table", { + level: "warning", + extra: result, + }); + } + + return Response.json(result); +} diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 8df930fd6..ad5d312ef 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/internal/email/feedback", "schedule": "30 15 * * *" + }, + { + "path": "/api/internal/domains/reconcile", + "schedule": "0 6 * * *" } ] } diff --git a/packages/api/src/lib/vercel.ts b/packages/api/src/lib/vercel.ts index f58c3d863..2386b9afc 100644 --- a/packages/api/src/lib/vercel.ts +++ b/packages/api/src/lib/vercel.ts @@ -1,54 +1,19 @@ -import { and, db as defaultDb, ne, sql } from "@openstatus/db"; -import { page } from "@openstatus/db/src/schema"; +import { ServiceError } from "@openstatus/services"; +import { vercelFetch as serviceVercelFetch } from "@openstatus/services/page"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { env } from "../env"; +import { toTRPCError } from "../service-adapter"; import { hasTrustedCertificate } from "./tls"; -// Vercel domain helpers — transport-layer external integrations that -// don't belong in the service layer. -const VERCEL_API_ORIGIN = "https://api.vercel.com"; - export async function vercelFetch(path: string, init?: RequestInit) { - // URL parsing resolves `..` segments and `#` cuts the query — refuse any - // path that doesn't survive normalization unchanged. - const url = new URL(path, VERCEL_API_ORIGIN); - if ( - url.origin !== VERCEL_API_ORIGIN || - url.hash || - `${url.pathname}${url.search}` !== path - ) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid path." }); - } - - return fetch(url, { - ...init, - headers: { - Authorization: `Bearer ${env.VERCEL_AUTH_BEARER_TOKEN}`, - "Content-Type": "application/json", - ...init?.headers, - }, - }); -} - -export async function addDomainToVercel(domain: string) { - const response = await vercelFetch( - `/v9/projects/${env.PROJECT_ID_VERCEL}/domains?teamId=${env.TEAM_ID_VERCEL}`, - { - body: JSON.stringify({ name: domain }), - 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); + try { + return await serviceVercelFetch(env.VERCEL_AUTH_BEARER_TOKEN, path, init); + } catch (err) { + if (err instanceof ServiceError) toTRPCError(err); + throw err; } - - return response.json(); } export const domainConfigResponseSchema = z.object({ @@ -112,83 +77,3 @@ export async function issueCertificateOnVercel(domain: string) { 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. 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.", - }); - } -} - -// customDomain has no unique constraint, so another workspace's page may -// hold the same domain — detaching it from Vercel would take their status -// page down. Only remove once no page row (minus excludePageId) references it. -export async function removeDomainFromVercelIfUnused( - db: typeof defaultDb, - domain: string, - opts?: { excludePageId?: number }, -) { - const holder = await db - .select({ id: page.id }) - .from(page) - .where( - and( - sql`lower(${page.customDomain}) = ${domain.toLowerCase()}`, - opts?.excludePageId !== undefined - ? ne(page.id, opts.excludePageId) - : undefined, - ), - ) - .get(); - - if (holder) { - console.warn("Skipping Vercel domain removal, still in use:", { - domain, - pageId: holder.id, - }); - return null; - } - - return removeDomainFromVercel(domain); -} - -export async function removeDomainFromVercel(domain: string) { - const response = await vercelFetch( - `/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${encodeURIComponent(domain)}?teamId=${env.TEAM_ID_VERCEL}`, - { 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/domain.test.ts b/packages/api/src/router/domain.test.ts index 10ee4cb3f..8bd640cb5 100644 --- a/packages/api/src/router/domain.test.ts +++ b/packages/api/src/router/domain.test.ts @@ -9,7 +9,6 @@ import { afterAll, beforeAll, test } from "@std/testing/bdd"; import { TRPCError } from "@trpc/server"; import { edgeRouter } from "../edge"; -import { vercelFetch } from "../lib/vercel"; import { createInnerTRPCContext } from "../trpc"; const otherDomain = "domain-idor-test.openstatus.dev"; @@ -205,16 +204,3 @@ test("domain.getCertificateStatus surfaces a Vercel config failure instead of re } }); }); - -for (const path of [ - "/v9/projects/p/domains/../../../../v2/user", - "/v9/projects/p/domains/%2e%2e/%2e%2e/x", - "/v9/projects/p/domains/a.com#?teamId=t", - "//evil.example/v2/user", -]) { - test(`vercelFetch refuses ${path}`, async () => { - const error = await vercelFetch(path).catch((e) => e); - expect(error).toBeInstanceOf(TRPCError); - expect((error as TRPCError).code).toBe("BAD_REQUEST"); - }); -} diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index b66e80d18..e8156c3b2 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -1,6 +1,6 @@ import { Events } from "@openstatus/analytics"; import { locales } from "@openstatus/locales"; -import { LimitExceededError, NotFoundError } from "@openstatus/services"; +import { NotFoundError } from "@openstatus/services"; import { getUptimeHistory } from "@openstatus/services/frozen-uptime"; import { createPage, @@ -10,7 +10,6 @@ import { CreatePageInput as CreatePageInputSchema, deletePage, getPage, - getPageCustomDomain, getSlugAvailable, listPages, newPage, @@ -19,7 +18,7 @@ import { UpdatePageAppearanceInput, updatePageConfiguration, UpdatePageConfigurationInput, - updatePageCustomDomain, + setPageCustomDomain, UpdatePageCustomDomainInput, updatePageCustomTheme, UpdatePageCustomThemeInput, @@ -31,10 +30,6 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { - addDomainToVercel, - removeDomainFromVercelIfUnused, -} from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -58,27 +53,10 @@ export const pageRouter = createTRPCRouter({ .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) => { try { - const sCtx = toServiceCtx(ctx); - const customDomain = await getPageCustomDomain({ - ctx: sCtx, - input: { id: input.id }, - }); await deletePage({ - ctx: sCtx, + ctx: toServiceCtx(ctx), input: { id: input.id }, }); - // best-effort: the page is gone either way, a leaked Vercel - // attachment is recoverable while a failed delete is not - if (customDomain) { - try { - await removeDomainFromVercelIfUnused(ctx.db, customDomain); - } catch (err) { - console.error("Failed to release domain from Vercel:", { - domain: customDomain, - error: err, - }); - } - } } catch (err) { if (err instanceof NotFoundError) return; toTRPCError(err); @@ -188,63 +166,10 @@ export const pageRouter = createTRPCRouter({ updateCustomDomain: protectedProcedure .meta({ track: Events.UpdatePageDomain, trackProps: ["customDomain"] }) - // Validate customDomain *before* the handler body runs — reusing - // the service's `UpdatePageCustomDomainInput` (backed by the - // canonical `customDomainSchema`) guarantees that malformed - // domains (`http://…`, `www.…`, format garbage) are rejected - // with a `ZodError` at tRPC's input layer, before any Vercel - // add/remove call fires. Previously the format check ran inside - // the service — reached only *after* Vercel mutations, which - // meant a bad input could leave Vercel holding a domain the db - // had then rejected. .input(UpdatePageCustomDomainInput) .mutation(async ({ ctx, input }) => { - if (input.customDomain.includes("openstatus")) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Domain cannot contain 'openstatus'", - }); - } - - // Resolve the existing domain via the service so the Vercel diff below - // sees the true pre-change state, then the service persists the new - // value. Vercel add/remove calls stay at the transport layer. - // - // `getPageCustomDomain` (narrow one-column read) instead of - // `getPage` (3 batched relation queries) — Vercel only needs the - // old domain string, so fanning out the full-relations read on - // every domain update was wasteful. try { - const sCtx = toServiceCtx(ctx); - if (input.customDomain && !sCtx.workspace.limits["custom-domain"]) { - throw new LimitExceededError("custom-domain", 0); - } - const oldDomain = await getPageCustomDomain({ - ctx: sCtx, - input: { id: input.id }, - }); - const newDomain = input.customDomain; - - // unchanged saves must be a no-op — re-adding an existing domain - // fails on Vercel; case-insensitive since DNS is and the schema - // doesn't lowercase, so a case-only re-save must not re-add either - if (newDomain.toLowerCase() === oldDomain.toLowerCase()) return; - - if (newDomain) { - await addDomainToVercel(newDomain); - } - if (oldDomain) { - // this page's row still holds oldDomain until the update below, - // so exclude it — any other holder keeps the domain on Vercel - await removeDomainFromVercelIfUnused(ctx.db, oldDomain, { - excludePageId: input.id, - }); - } - - await updatePageCustomDomain({ - ctx: sCtx, - input: { id: input.id, customDomain: newDomain }, - }); + await setPageCustomDomain({ ctx: toServiceCtx(ctx), input }); } catch (err) { toTRPCError(err); } diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 7a3cfc211..ebe9d1bf6 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -12,6 +12,7 @@ import { stripeIdempotencyKey, } from "@openstatus/emails"; import type { ServiceContext } from "@openstatus/services"; +import { detachDomainIfUnused } from "@openstatus/services/page"; import { type DowngradeTrim, downgradeWorkspaceToFree, @@ -24,7 +25,6 @@ import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; -import { removeDomainFromVercelIfUnused } from "../../lib/vercel"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { buildFromSubscriptionOrThrow, @@ -596,7 +596,7 @@ export const webhookRouter = createTRPCRouter({ // commit: a Vercel error must not fail the webhook into Stripe retries. for (const domain of customDomains) { try { - await removeDomainFromVercelIfUnused(opts.ctx.db, domain); + await detachDomainIfUnused({ db: opts.ctx.db, domain }); } catch (err) { console.error("Failed to release domain from Vercel:", { domain, diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 3b540b4eb..ee8e8970d 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -1,8 +1,8 @@ import { PreconditionFailedError } from "@openstatus/services"; +import { detachDomainIfUnused } from "@openstatus/services/page"; import { deleteAccount } from "@openstatus/services/user"; import { listOwnedWorkspaces } from "@openstatus/services/workspace"; -import { removeDomainFromVercelIfUnused } from "../lib/vercel"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { cancelOwnedTrials } from "./stripe/trial"; @@ -41,7 +41,7 @@ export const userRouter = createTRPCRouter({ // input needed. await deleteAccount({ ctx: toServiceCtx(ctx) }); for (const domain of customDomains) { - await removeDomainFromVercelIfUnused(ctx.db, domain).catch((error) => + await detachDomainIfUnused({ db: ctx.db, domain }).catch((error) => console.error("Failed to release domain from Vercel:", { domain, error, diff --git a/packages/services/src/context.ts b/packages/services/src/context.ts index fa350ce4b..e4c7477e8 100644 --- a/packages/services/src/context.ts +++ b/packages/services/src/context.ts @@ -2,6 +2,7 @@ import { SQLiteTransaction, db as defaultDb, is } from "@openstatus/db"; import type { Scope, Workspace } from "@openstatus/db/src/schema"; import { OSTinybird } from "@openstatus/tinybird"; +import type { VercelDomainConfig } from "./page/domain-sync"; import { withBusyRetry } from "./retry"; import type { WorkOSClient } from "./sso/client"; @@ -32,6 +33,8 @@ export type ServiceContext = { db?: DB; tb?: OSTinybird; workos?: WorkOSClient; + /** Unset reads the env; `null` disables Vercel domain sync. */ + vercel?: VercelDomainConfig | null; }; // Fallback for callers with no app env to read from (scripts, tests). Request diff --git a/packages/services/src/page/__tests__/domain-sync.test.ts b/packages/services/src/page/__tests__/domain-sync.test.ts new file mode 100644 index 000000000..4342edf6f --- /dev/null +++ b/packages/services/src/page/__tests__/domain-sync.test.ts @@ -0,0 +1,205 @@ +import { page as pageTable } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { afterEach, beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + withTestTransaction, +} from "../../../test/helpers"; +import { + ConflictError, + InternalServiceError, + ValidationError, +} from "../../errors"; +import { + attachDomain, + detachDomain, + detachDomainIfUnused, + reconcileProjectDomains, + type VercelDomainConfig, + vercelFetch, +} from "../domain-sync"; + +const config: VercelDomainConfig = { + projectId: "prj_test", + teamId: "team_test", + token: "token", +}; + +type Call = { method: string; url: URL }; +const originalFetch = globalThis.fetch; +let calls: Call[] = []; + +function mockVercel(handler: (call: Call) => Response) { + calls = []; + globalThis.fetch = (input, init) => { + const request = new Request(input, init); + const call = { method: request.method, url: new URL(request.url) }; + calls.push(call); + return Promise.resolve(handler(call)); + }; +} + +const errorResponse = (status: number, code: string) => + Response.json({ error: { code, message: "vercel internals" } }, { status }); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +let workspaceId: number; +beforeAll(async () => { + workspaceId = (await createWorkspaceFixture("team")).workspace.id; +}); + +describe("detachDomain", () => { + test("treats 404 as detached", async () => { + mockVercel(() => errorResponse(404, "not_found")); + await detachDomain(config, "gone.example.com"); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("DELETE"); + }); + + test("throws on 500", async () => { + mockVercel(() => errorResponse(500, "internal")); + await expect( + detachDomain(config, "boom.example.com"), + ).rejects.toBeInstanceOf(InternalServiceError); + }); + + test("no-op without config", async () => { + mockVercel(() => Response.json({})); + await detachDomain(null, "selfhost.example.com"); + expect(calls).toHaveLength(0); + }); +}); + +describe("attachDomain", () => { + test("falls back to GET and accepts an existing attachment", async () => { + mockVercel(({ method }) => + method === "POST" + ? errorResponse(409, "domain_already_in_use") + : Response.json({ name: "again.example.com" }), + ); + await attachDomain(config, "again.example.com"); + expect(calls.map((c) => c.method)).toEqual(["POST", "GET"]); + }); + + test("throws the mapped error when the domain is not attached", async () => { + mockVercel(({ method }) => + method === "POST" + ? errorResponse(409, "domain_already_in_use") + : errorResponse(404, "not_found"), + ); + const error = await attachDomain(config, "taken.example.com").catch( + (e) => e, + ); + expect(error).toBeInstanceOf(ConflictError); + expect((error as Error).message).not.toContain("vercel internals"); + }); +}); + +describe("detachDomainIfUnused", () => { + test("skips when another page holds the domain, proceeds with excludePageId", async () => { + await withTestTransaction(async (tx) => { + const domain = `held-${crypto.randomUUID()}.example.com`; + const holder = await tx + .insert(pageTable) + .values({ + workspaceId, + title: "Holder", + slug: `svc-domain-sync-${crypto.randomUUID()}`, + description: "", + customDomain: domain.toUpperCase(), + }) + .returning() + .get(); + + mockVercel(() => Response.json({})); + await detachDomainIfUnused({ db: tx, domain, config }); + expect(calls).toHaveLength(0); + + await detachDomainIfUnused({ + db: tx, + domain, + excludePageId: holder.id, + config, + }); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe("DELETE"); + }); + }); +}); + +describe("reconcileProjectDomains", () => { + test("splits orphans and missing, skips own domains, follows pagination", async () => { + await withTestTransaction(async (tx) => { + const id = crypto.randomUUID(); + const held = `held-${id}.example.com`; + const orphan = `orphan-${id}.example.com`; + const missing = `missing-${id}.example.com`; + for (const domain of [held, missing]) { + await tx + .insert(pageTable) + .values({ + workspaceId, + title: "Reconcile", + slug: `svc-domain-sync-${crypto.randomUUID()}`, + description: "", + customDomain: domain, + }) + .run(); + } + + mockVercel(({ method, url }) => { + if (method === "DELETE") return Response.json({}); + return url.searchParams.has("until") + ? Response.json({ + domains: [ + { name: orphan }, + { name: "www.x.com", redirect: "x.com" }, + ], + pagination: { next: null }, + }) + : Response.json({ + domains: [ + { name: held.toUpperCase() }, + { name: "openstatus.dev" }, + { name: "acme.openstatus.dev" }, + { name: "preview.example.com", gitBranch: "main" }, + ], + pagination: { next: 123 }, + }); + }); + + const report = await reconcileProjectDomains({ db: tx, config }); + expect(report.orphans).toEqual([orphan]); + expect(report.missing).toContain(missing); + expect(report.missing).not.toContain(held); + expect(report.detached).toEqual([]); + expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0); + + const applied = await reconcileProjectDomains({ + db: tx, + config, + apply: true, + }); + expect(applied.detached).toEqual([orphan]); + const deletes = calls.filter((c) => c.method === "DELETE"); + expect(deletes).toHaveLength(1); + expect(deletes[0].url.pathname).toContain(encodeURIComponent(orphan)); + }); + }); +}); + +for (const path of [ + "/v9/projects/p/domains/../../../../v2/user", + "/v9/projects/p/domains/%2e%2e/%2e%2e/x", + "/v9/projects/p/domains/a.com#?teamId=t", + "//evil.example/v2/user", +]) { + test(`vercelFetch refuses ${path}`, async () => { + const error = await vercelFetch("token", path).catch((e) => e); + expect(error).toBeInstanceOf(ValidationError); + }); +} diff --git a/packages/services/src/page/__tests__/set-custom-domain.test.ts b/packages/services/src/page/__tests__/set-custom-domain.test.ts new file mode 100644 index 000000000..edda6380d --- /dev/null +++ b/packages/services/src/page/__tests__/set-custom-domain.test.ts @@ -0,0 +1,229 @@ +import { eq } from "@openstatus/db"; +import { page as pageTable } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { afterEach, beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeApiKeyCtx, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { DrizzleTx, ServiceContext } from "../../context"; +import { + ForbiddenError, + InternalServiceError, + LimitExceededError, + ValidationError, +} from "../../errors"; +import { newPage } from "../create"; +import { deletePage } from "../delete"; +import type { VercelDomainConfig } from "../domain-sync"; +import { setPageCustomDomain } from "../set-custom-domain"; + +const vercel: VercelDomainConfig = { + projectId: "prj_test", + teamId: "team_test", + token: "token", +}; + +type Call = { method: string; url: URL }; +const originalFetch = globalThis.fetch; +let calls: Call[] = []; + +function mockVercel(handler: (call: Call) => Response) { + calls = []; + globalThis.fetch = (input, init) => { + const request = new Request(input, init); + const call = { method: request.method, url: new URL(request.url) }; + calls.push(call); + return Promise.resolve(handler(call)); + }; +} + +const ok = () => Response.json({}); +const status = (code: number) => () => + Response.json({ error: { code: "x" } }, { status: code }); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +beforeAll(async () => { + teamCtx = { + ...makeUserCtx((await createWorkspaceFixture("team")).workspace), + vercel, + }; + freeCtx = { + ...makeUserCtx((await createWorkspaceFixture("free")).workspace), + vercel, + }; +}); + +async function pageWithDomain( + tx: DrizzleTx, + ctx: ServiceContext, + customDomain: string, +) { + const p = await newPage({ + ctx: { ...ctx, db: tx }, + input: { title: "Domain", slug: `svc-set-domain-${crypto.randomUUID()}` }, + }); + if (customDomain) { + await tx + .update(pageTable) + .set({ customDomain }) + .where(eq(pageTable.id, p.id)) + .run(); + } + return p.id; +} + +async function storedDomain(tx: DrizzleTx, id: number) { + const row = await tx + .select({ customDomain: pageTable.customDomain }) + .from(pageTable) + .where(eq(pageTable.id, id)) + .get(); + return row?.customDomain; +} + +const domain = () => `${crypto.randomUUID()}.example.com`; + +describe("setPageCustomDomain", () => { + test("clearing against a 404 still writes the row", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const id = await pageWithDomain(tx, teamCtx, domain()); + mockVercel(status(404)); + await setPageCustomDomain({ ctx, input: { id, customDomain: "" } }); + expect(await storedDomain(tx, id)).toBe(""); + }); + }); + + test("clearing against a 500 leaves the row unchanged", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const old = domain(); + const id = await pageWithDomain(tx, teamCtx, old); + mockVercel(status(500)); + await expect( + setPageCustomDomain({ ctx, input: { id, customDomain: "" } }), + ).rejects.toBeInstanceOf(InternalServiceError); + expect(await storedDomain(tx, id)).toBe(old); + }); + }); + + test("a swap whose detach fails is retryable", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const old = domain(); + const next = domain(); + const id = await pageWithDomain(tx, teamCtx, old); + + mockVercel(({ method }) => (method === "DELETE" ? status(500)() : ok())); + await expect( + setPageCustomDomain({ ctx, input: { id, customDomain: next } }), + ).rejects.toBeInstanceOf(InternalServiceError); + expect(await storedDomain(tx, id)).toBe(old); + + // retry: attach reports "already attached", GET confirms, detach succeeds + mockVercel(({ method }) => + method === "POST" ? status(409)() : Response.json({ name: next }), + ); + await setPageCustomDomain({ ctx, input: { id, customDomain: next } }); + expect(calls.map((c) => c.method)).toEqual(["POST", "GET", "DELETE"]); + expect(await storedDomain(tx, id)).toBe(next); + }); + }); + + test("an unchanged save makes no Vercel call", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const current = domain(); + const id = await pageWithDomain(tx, teamCtx, current); + mockVercel(ok); + await setPageCustomDomain({ + ctx, + input: { id, customDomain: current.toUpperCase() }, + }); + expect(calls).toHaveLength(0); + }); + }); + + test("rejects an 'openstatus' substring", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const id = await pageWithDomain(tx, teamCtx, ""); + mockVercel(ok); + await expect( + setPageCustomDomain({ + ctx, + input: { id, customDomain: "status.openstatus.example.com" }, + }), + ).rejects.toBeInstanceOf(ValidationError); + expect(calls).toHaveLength(0); + }); + }); + + test("enforces the custom-domain limit", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...freeCtx, db: tx }; + const id = await pageWithDomain(tx, freeCtx, ""); + mockVercel(ok); + await expect( + setPageCustomDomain({ ctx, input: { id, customDomain: domain() } }), + ).rejects.toBeInstanceOf(LimitExceededError); + expect(calls).toHaveLength(0); + }); + }); + + test("rejects read-only actor", async () => { + const ctx = makeApiKeyCtx(teamCtx.workspace, { + keyId: "read-only", + scopes: ["read"], + }); + await expect( + setPageCustomDomain({ ctx, input: { id: 1, customDomain: "" } }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); +}); + +describe("deletePage domain release", () => { + test("detaches the domain, then deletes the row", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const id = await pageWithDomain(tx, teamCtx, domain()); + mockVercel(ok); + await deletePage({ ctx, input: { id } }); + expect(calls.map((c) => c.method)).toEqual(["DELETE"]); + expect(await storedDomain(tx, id)).toBeUndefined(); + }); + }); + + test("a Vercel 500 keeps the row and surfaces the error", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const current = domain(); + const id = await pageWithDomain(tx, teamCtx, current); + mockVercel(status(500)); + await expect(deletePage({ ctx, input: { id } })).rejects.toBeInstanceOf( + InternalServiceError, + ); + expect(await storedDomain(tx, id)).toBe(current); + }); + }); + + test("releaseDomain: false makes no call", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const id = await pageWithDomain(tx, teamCtx, domain()); + mockVercel(ok); + await deletePage({ ctx, input: { id }, releaseDomain: false }); + expect(calls).toHaveLength(0); + expect(await storedDomain(tx, id)).toBeUndefined(); + }); + }); +}); diff --git a/packages/services/src/page/delete.ts b/packages/services/src/page/delete.ts index aa8c5ffe5..cfac7bfd6 100644 --- a/packages/services/src/page/delete.ts +++ b/packages/services/src/page/delete.ts @@ -3,19 +3,39 @@ import { page } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; -import { type ServiceContext, withTransaction } from "../context"; +import { getReadDb, type ServiceContext, withTransaction } from "../context"; +import { detachDomainIfUnused, resolveVercelConfig } from "./domain-sync"; import { getPageInWorkspace } from "./internal"; +import { getPageCustomDomain } from "./list"; import { DeletePageInput } from "./schemas"; -/** Delete a page. FK cascade clears pageComponents / statusReports / … */ +/** + * Delete a page. FK cascade clears pageComponents / statusReports / … + * Detaches the custom domain from Vercel first, so a Vercel failure fails the + * delete and stays retryable. Callers already inside a transaction pass + * `releaseDomain: false` and release after commit. + */ export async function deletePage(args: { ctx: ServiceContext; input: DeletePageInput; + releaseDomain?: boolean; }): Promise { - const { ctx } = args; + const { ctx, releaseDomain = true } = args; requireScope(ctx, "write"); const input = DeletePageInput.parse(args.input); + if (releaseDomain) { + const domain = await getPageCustomDomain({ ctx, input: { id: input.id } }); + if (domain) { + await detachDomainIfUnused({ + db: getReadDb(ctx), + domain, + excludePageId: input.id, + config: resolveVercelConfig(ctx), + }); + } + } + await withTransaction(ctx, async (tx) => { const existing = await getPageInWorkspace({ tx, diff --git a/packages/services/src/page/domain-sync.ts b/packages/services/src/page/domain-sync.ts new file mode 100644 index 000000000..cad0fdd41 --- /dev/null +++ b/packages/services/src/page/domain-sync.ts @@ -0,0 +1,286 @@ +import { and, ne, sql } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; + +import type { DB, ServiceContext } from "../context"; +import { + ConflictError, + ForbiddenError, + InternalServiceError, + ServiceError, + ValidationError, +} from "../errors"; + +const VERCEL_API_ORIGIN = "https://api.vercel.com"; +const TIMEOUT_MS = 5_000; + +export type VercelDomainConfig = { + projectId: string; + teamId: string; + token: string; +}; + +/** `null` on self-host, where every sync call is a no-op. */ +export function vercelConfigFromEnv(): VercelDomainConfig | null { + const projectId = process.env.PROJECT_ID_VERCEL; + const teamId = process.env.TEAM_ID_VERCEL; + const token = process.env.VERCEL_AUTH_BEARER_TOKEN; + if (!projectId || !teamId || !token) return null; + return { projectId, teamId, token }; +} + +export function resolveVercelConfig( + ctx: Pick, +): VercelDomainConfig | null { + return ctx.vercel !== undefined ? ctx.vercel : vercelConfigFromEnv(); +} + +export async function vercelFetch( + token: string | undefined, + path: string, + init?: RequestInit, +) { + // URL parsing resolves `..` segments and `#` cuts the query — refuse any + // path that doesn't survive normalization unchanged. + const url = new URL(path, VERCEL_API_ORIGIN); + if ( + url.origin !== VERCEL_API_ORIGIN || + url.hash || + `${url.pathname}${url.search}` !== path + ) { + throw new ValidationError("Invalid path."); + } + + return fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...init?.headers, + }, + }); +} + +async function syncFetch( + config: VercelDomainConfig, + path: string, + init: RequestInit & { failure: string }, +) { + const { failure, ...rest } = init; + try { + return await vercelFetch(config.token, path, { + ...rest, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (err) { + if (err instanceof ServiceError) throw err; + console.warn("Vercel request failed:", { path, error: err }); + throw new InternalServiceError(failure, err); + } +} + +function projectDomainPath(config: VercelDomainConfig, domain: string) { + return `/v9/projects/${config.projectId}/domains/${encodeURIComponent(domain)}?teamId=${config.teamId}`; +} + +const ADD_FAILED = + "Failed to add custom domain. Please try again. If it continues, contact support."; +const REMOVE_FAILED = + "Failed to remove custom domain. Please try again. If it continues, contact support."; + +export type ProjectDomain = { + name: string; + redirect?: string | null; + gitBranch?: string | null; + verified?: boolean; +}; + +export async function getProjectDomain( + config: VercelDomainConfig, + domain: string, +): Promise { + const response = await syncFetch(config, projectDomainPath(config, domain), { + failure: ADD_FAILED, + }); + if (response.status === 404) return null; + if (!response.ok) { + const error = await response.json().catch(() => ({})); + console.warn("Failed to read domain from Vercel:", { domain, error }); + throw new InternalServiceError(ADD_FAILED); + } + return response.json(); +} + +/** Idempotent: a domain already attached to the project counts as success. */ +export async function attachDomain( + config: VercelDomainConfig | null, + domain: string, +): Promise { + if (!config) return; + const response = await syncFetch( + config, + `/v9/projects/${config.projectId}/domains?teamId=${config.teamId}`, + { + method: "POST", + body: JSON.stringify({ name: domain }), + failure: ADD_FAILED, + }, + ); + if (response.ok) return; + + const error = await response.json().catch(() => ({})); + if (await getProjectDomain(config, domain)) return; + console.warn("Failed to add domain to Vercel:", { domain, error }); + throw toDomainError(domain, error?.error?.code); +} + +/** Idempotent: a 404 counts as detached. */ +export async function detachDomain( + config: VercelDomainConfig | null, + domain: string, +): Promise { + if (!config) return; + const response = await syncFetch(config, projectDomainPath(config, domain), { + method: "DELETE", + failure: REMOVE_FAILED, + }); + if (response.ok || response.status === 404) return; + + const error = await response.json().catch(() => ({})); + console.warn("Failed to remove domain from Vercel:", { domain, error }); + throw new InternalServiceError(REMOVE_FAILED); +} + +// customDomain has no unique constraint, so another workspace's page may +// hold the same domain — detaching it would take their status page down. +export async function detachDomainIfUnused(args: { + db: DB; + domain: string; + excludePageId?: number; + config?: VercelDomainConfig | null; +}): Promise { + const config = resolveVercelConfig({ vercel: args.config }); + if (!config) return; + + const holder = await args.db + .select({ id: page.id }) + .from(page) + .where( + and( + sql`lower(${page.customDomain}) = ${args.domain.toLowerCase()}`, + args.excludePageId !== undefined + ? ne(page.id, args.excludePageId) + : undefined, + ), + ) + .get(); + + if (holder) { + console.warn("Skipping Vercel domain removal, still in use:", { + domain: args.domain, + pageId: holder.id, + }); + return; + } + + await detachDomain(config, args.domain); +} + +export async function listProjectDomains( + config: VercelDomainConfig, +): Promise { + const domains: ProjectDomain[] = []; + let until: number | null = null; + do { + const path = `/v9/projects/${config.projectId}/domains?teamId=${config.teamId}&limit=100${until ? `&until=${until}` : ""}`; + const response = await syncFetch(config, path, { + failure: "Failed to list project domains.", + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new InternalServiceError( + `Failed to list project domains (${response.status}).`, + error, + ); + } + const json: { + domains: ProjectDomain[]; + pagination?: { next: number | null }; + } = await response.json(); + domains.push(...json.domains); + until = json.pagination?.next ?? null; + } while (until); + return domains; +} + +// Vercel messages leak internal project details, so map known codes to our own copy. +function toDomainError(domain: string, code?: string): ServiceError { + switch (code) { + case "domain_already_in_use": + return new ConflictError( + `The domain '${domain}' is already in use. Remove it there first or contact support.`, + ); + case "invalid_domain": + case "not_found": + return new ValidationError(`The domain '${domain}' is invalid.`); + case "forbidden": + case "domain_taken": + return new ForbiddenError( + `The domain '${domain}' belongs to another team on our hosting provider. Contact support if you own it.`, + ); + default: + return new InternalServiceError(ADD_FAILED); + } +} + +function isOwnDomain(d: ProjectDomain) { + const name = d.name.toLowerCase(); + return ( + name === "openstatus.dev" || + name.endsWith(".openstatus.dev") || + !!d.redirect || + !!d.gitBranch + ); +} + +/** + * Orphans are attached on Vercel but held by no page; missing are held by a + * page but not attached. `apply` detaches orphans; missing stays report-only + * because an attach can legitimately fail (domain owned by another team). + */ +export async function reconcileProjectDomains(args: { + db: DB; + config: VercelDomainConfig; + apply?: boolean; +}): Promise<{ orphans: string[]; missing: string[]; detached: string[] }> { + const attached = (await listProjectDomains(args.config)) + .filter((d) => !isOwnDomain(d)) + .map((d) => d.name.toLowerCase()); + + const rows = await args.db + .selectDistinct({ domain: sql`lower(${page.customDomain})` }) + .from(page) + .where(ne(page.customDomain, "")) + .all(); + const held = new Set(rows.map((r) => r.domain)); + const attachedSet = new Set(attached); + + const orphans = attached.filter((d) => !held.has(d)).sort(); + const missing = [...held].filter((d) => !attachedSet.has(d)).sort(); + + const detached: string[] = []; + if (args.apply) { + for (const domain of orphans) { + try { + await detachDomain(args.config, domain); + detached.push(domain); + } catch (err) { + console.warn("Failed to detach orphaned domain:", { + domain, + error: err, + }); + } + } + } + + return { orphans, missing, detached }; +} diff --git a/packages/services/src/page/index.ts b/packages/services/src/page/index.ts index 8bc4fabf3..fe119cf0a 100644 --- a/packages/services/src/page/index.ts +++ b/packages/services/src/page/index.ts @@ -1,5 +1,18 @@ export { createPage, newPage } from "./create"; export { deletePage } from "./delete"; +export { + attachDomain, + detachDomain, + detachDomainIfUnused, + getProjectDomain, + listProjectDomains, + reconcileProjectDomains, + type ProjectDomain, + resolveVercelConfig, + type VercelDomainConfig, + vercelConfigFromEnv, + vercelFetch, +} from "./domain-sync"; export { type MaintenanceContent, type StatusPageContent, @@ -16,6 +29,7 @@ export { type PageListItem, type PageWithRelations, } from "./list"; +export { setPageCustomDomain } from "./set-custom-domain"; export { updatePageAppearance, updatePageConfiguration, diff --git a/packages/services/src/page/set-custom-domain.ts b/packages/services/src/page/set-custom-domain.ts new file mode 100644 index 000000000..878665001 --- /dev/null +++ b/packages/services/src/page/set-custom-domain.ts @@ -0,0 +1,54 @@ +import { requireScope } from "../auth"; +import { getReadDb, type ServiceContext } from "../context"; +import { LimitExceededError, ValidationError } from "../errors"; +import { + attachDomain, + detachDomainIfUnused, + resolveVercelConfig, +} from "./domain-sync"; +import { getPageCustomDomain } from "./list"; +import { UpdatePageCustomDomainInput } from "./schemas"; +import { updatePageCustomDomain } from "./update"; + +/** + * Set or clear a page's custom domain and keep the Vercel project in sync. + * Not transactional on purpose: an HTTP round-trip inside a libSQL write + * transaction blocks every other writer. Attach → detach → write self-heals on + * retry because both Vercel calls are idempotent. + */ +export async function setPageCustomDomain(args: { + ctx: ServiceContext; + input: UpdatePageCustomDomainInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdatePageCustomDomainInput.parse(args.input); + const next = input.customDomain; + + if (next && !ctx.workspace.limits["custom-domain"]) { + throw new LimitExceededError("custom-domain", 0); + } + if (next.toLowerCase().includes("openstatus")) { + throw new ValidationError("Domain cannot contain 'openstatus'"); + } + + const previous = await getPageCustomDomain({ ctx, input: { id: input.id } }); + // Case-only changes skip Vercel too: DNS is case-insensitive. + if (previous.toLowerCase() === next.toLowerCase()) return; + + const config = resolveVercelConfig(ctx); + if (next) await attachDomain(config, next); + if (previous) { + await detachDomainIfUnused({ + db: getReadDb(ctx), + domain: previous, + excludePageId: input.id, + config, + }); + } + + await updatePageCustomDomain({ + ctx, + input: { id: input.id, customDomain: next }, + }); +} diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts index e71cd3135..b04963fe9 100644 --- a/packages/services/src/user/delete.ts +++ b/packages/services/src/user/delete.ts @@ -152,7 +152,7 @@ export async function deleteAccount(args: { .all() ).map((p) => p.id); for (const id of pageIds) { - await deletePage({ ctx: subCtx, input: { id } }); + await deletePage({ ctx: subCtx, input: { id }, releaseDomain: false }); } const notificationIds = ( diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts index 20b6cd6e8..aad5499e8 100644 --- a/packages/services/src/workspace/downgrade.ts +++ b/packages/services/src/workspace/downgrade.ts @@ -142,7 +142,11 @@ export async function downgradeWorkspaceToFree(args: { ]; for (const p of statusPages.slice(1)) { - await deletePage({ ctx: txCtx, input: { id: p.id } }); + await deletePage({ + ctx: txCtx, + input: { id: p.id }, + releaseDomain: false, + }); } // Strip the surviving page's paid-only access features. Both verbs -- 2.51.2