From 4fbf772a98ac867c75b7edccc3bce60d0f76343e Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 6 Oct 2026 10:55:48 +0200 Subject: [PATCH] wip: --- .../routes/rpc/handlers/status-page/index.ts | 13 ++++-- .../api/internal/domains/reconcile/route.ts | 2 + .../page/__tests__/set-custom-domain.test.ts | 44 ++++++++----------- packages/services/src/page/create.ts | 8 ++-- packages/services/src/page/domain-sync.ts | 1 + .../services/src/page/set-custom-domain.ts | 3 +- 6 files changed, 37 insertions(+), 34 deletions(-) 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 719f52889..7fdab78ac 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -652,11 +652,18 @@ export const statusPageServiceImpl: ServiceImpl = { input: { id: created.id, customDomain }, }); } catch (err) { - // The attach may have landed before the failure; release it once - // the page is gone (skipped if another page holds the domain). + // Mapped service errors (limit, conflict, invalid, forbidden) fire + // before or instead of an attach; anything else may follow one. + const mayHaveAttached = + !(err instanceof ServiceError) || err.code === "INTERNAL"; try { await deletePage({ ctx: sCtx, input: { id: created.id } }); - await detachDomainIfUnused({ domain: customDomain }); + if (mayHaveAttached) { + await detachDomainIfUnused({ + domain: customDomain, + config: sCtx.vercel, + }); + } } catch (cleanupErr) { console.error("Failed to roll back status page create:", { pageId: created.id, diff --git a/apps/web/src/app/api/internal/domains/reconcile/route.ts b/apps/web/src/app/api/internal/domains/reconcile/route.ts index 9044b9ffe..68f8708a4 100644 --- a/apps/web/src/app/api/internal/domains/reconcile/route.ts +++ b/apps/web/src/app/api/internal/domains/reconcile/route.ts @@ -6,6 +6,8 @@ import { import { captureMessage } from "@sentry/nextjs"; import type { NextRequest } from "next/server"; +// Report-only unless DOMAIN_RECONCILE_APPLY=1. "missing" is never re-attached: +// re-saving the domain in the dashboard heals that page. export async function GET(request: NextRequest) { const secret = process.env.CRON_SECRET; const authHeader = request.headers.get("authorization"); diff --git a/packages/services/src/page/__tests__/set-custom-domain.test.ts b/packages/services/src/page/__tests__/set-custom-domain.test.ts index 35ef1323d..4cdb8840d 100644 --- a/packages/services/src/page/__tests__/set-custom-domain.test.ts +++ b/packages/services/src/page/__tests__/set-custom-domain.test.ts @@ -1,13 +1,7 @@ import { db, eq } from "@openstatus/db"; import { page as pageTable } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; -import { - afterAll, - afterEach, - beforeAll, - describe, - test, -} from "@std/testing/bdd"; +import { afterEach, beforeAll, describe, test } from "@std/testing/bdd"; import { clearAuditLogFor, @@ -241,21 +235,22 @@ describe("setPageCustomDomain", () => { }); }); -test("createPage never writes a custom domain", async () => { +test("createPage rejects a custom domain instead of dropping it", async () => { await withTestTransaction(async (tx) => { mockVercel(ok); - const p = await createPage({ - ctx: { ...teamCtx, db: tx }, - input: { - title: "Create", - description: "", - slug: `svc-set-domain-${crypto.randomUUID()}`, - customDomain: domain(), - workspaceId: teamCtx.workspace.id, - monitors: [], - }, - }); - expect(p.customDomain).toBe(""); + await expect( + createPage({ + ctx: { ...teamCtx, db: tx }, + input: { + title: "Create", + description: "", + slug: `svc-set-domain-${crypto.randomUUID()}`, + customDomain: domain(), + workspaceId: teamCtx.workspace.id, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(ValidationError); expect(calls).toHaveLength(0); }); }); @@ -264,18 +259,15 @@ describe("deletePage domain release", () => { // Committed db: inside a transaction deletePage deliberately skips Vercel. const created: number[] = []; afterEach(async () => { - for (const id of created.splice(0)) { + const ids = created.splice(0); + for (const id of ids) { await db.delete(pageTable).where(eq(pageTable.id, id)); } + await clearAuditLogFor({ entityType: "page", entityIds: ids }); }); - afterAll(async () => { - await clearAuditLogFor({ entityType: "page", entityIds: auditIds }); - }); - const auditIds: number[] = []; async function committedPage(customDomain: string) { const id = await pageWithDomain(db, teamCtx, customDomain); created.push(id); - auditIds.push(id); return id; } diff --git a/packages/services/src/page/create.ts b/packages/services/src/page/create.ts index 6e148a785..0a1ec5c98 100644 --- a/packages/services/src/page/create.ts +++ b/packages/services/src/page/create.ts @@ -7,7 +7,7 @@ import { import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; -import { LimitExceededError } from "../errors"; +import { LimitExceededError, ValidationError } from "../errors"; import { assertWithinLimit } from "../limits"; import type { Page } from "../types"; import { @@ -26,6 +26,9 @@ export async function createPage(args: { requireScope(ctx, "write"); const input = CreatePageInput.parse(args.input); + if (input.customDomain) { + throw new ValidationError("Set the custom domain after creating the page."); + } if (input.customTheme && !ctx.workspace.limits["custom-theme"]) { throw new LimitExceededError("custom-theme", 0); } @@ -70,9 +73,6 @@ export async function createPage(args: { // JSON string, breaking downstream reads that expect an object. configuration, ...pageProps, - // Domains go through `setPageCustomDomain`, which checks the plan - // and attaches on Vercel. - customDomain: "", authEmailDomains: pageProps.authEmailDomains?.join(","), allowedIpRanges: pageProps.allowedIpRanges?.join(","), }) diff --git a/packages/services/src/page/domain-sync.ts b/packages/services/src/page/domain-sync.ts index ec1dd7ddf..40150470c 100644 --- a/packages/services/src/page/domain-sync.ts +++ b/packages/services/src/page/domain-sync.ts @@ -174,6 +174,7 @@ export async function findDomainHolder(args: { // customDomain has no unique constraint, so another workspace's page may // hold the same domain — detaching it would take their status page down. +// Concurrent deletes of the last two holders can both skip; reconcile catches it. // Returns whether the domain was detached. export async function detachDomainIfUnused(args: { db?: DB; diff --git a/packages/services/src/page/set-custom-domain.ts b/packages/services/src/page/set-custom-domain.ts index b2bb32afa..a856f60da 100644 --- a/packages/services/src/page/set-custom-domain.ts +++ b/packages/services/src/page/set-custom-domain.ts @@ -42,7 +42,8 @@ export async function setPageCustomDomain(args: { throw new ValidationError("Domain cannot contain 'openstatus'"); } // Attach is idempotent per project, so it can't tell our own page's - // attachment from another workspace's — the row check has to. + // attachment from another workspace's — the row check has to. Not atomic: + // concurrent claims can both pass without a unique index. if ( next && (await findDomainHolder({ -- 2.51.2