From e89b614b7b838e4831699c5789cd68f17fa8fbd6 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:20:18 +0000 Subject: [PATCH] fix(page): reject custom domains without the workspace feature (#2671) * fix(page): reject custom domains without the workspace feature * test(api): remove page router test file --- packages/api/src/router/page.ts | 17 +++-- .../services/src/page/__tests__/page.test.ts | 71 +++++++++++++++++++ packages/services/src/page/update.ts | 4 ++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index 6566567e..b66e80d1 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -1,17 +1,13 @@ import { Events } from "@openstatus/analytics"; import { locales } from "@openstatus/locales"; -import { NotFoundError } from "@openstatus/services"; +import { LimitExceededError, NotFoundError } from "@openstatus/services"; import { getUptimeHistory } from "@openstatus/services/frozen-uptime"; import { + createPage, type CreatePageInput, // `CreatePageInput` re-exports the drizzle insert schema so routers // don't need to import it directly from `@openstatus/db`. CreatePageInput as CreatePageInputSchema, - UpdatePageAppearanceInput, - UpdatePageConfigurationInput, - UpdatePageCustomThemeInput, - UpdatePageCustomDomainInput, - createPage, deletePage, getPage, getPageCustomDomain, @@ -20,9 +16,13 @@ import { newPage, pageAccessTypes, updatePageAppearance, + UpdatePageAppearanceInput, updatePageConfiguration, - updatePageCustomTheme, + UpdatePageConfigurationInput, updatePageCustomDomain, + UpdatePageCustomDomainInput, + updatePageCustomTheme, + UpdatePageCustomThemeInput, updatePageGeneral, updatePageLinks, updatePageLocales, @@ -216,6 +216,9 @@ export const pageRouter = createTRPCRouter({ // 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 }, diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts index 9f3377d5..3d248694 100644 --- a/packages/services/src/page/__tests__/page.test.ts +++ b/packages/services/src/page/__tests__/page.test.ts @@ -31,6 +31,7 @@ import { getPage, getPageBySlug, getSlugAvailable, listPages } from "../list"; import { type CreatePageInput, UpdatePageConfigurationInput } from "../schemas"; import { updatePageConfiguration, + updatePageCustomDomain, updatePageCustomTheme, updatePageGeneral, updatePageLocales, @@ -477,6 +478,76 @@ describe("updatePageGeneral", () => { }); }); +describe("updatePageCustomDomain", () => { + test("rejects a domain without the feature and preserves the stored domain", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...freeCtx, db: tx }; + const p = await newPage({ + ctx, + input: { title: "Domain", slug: uniqueSlug("domain-denied") }, + }); + await expect( + updatePageCustomDomain({ + ctx, + input: { id: p.id, customDomain: "status.example.com" }, + }), + ).rejects.toMatchObject({ + code: "LIMIT_EXCEEDED", + max: 0, + }); + const row = await tx + .select() + .from(pageTable) + .where(eq(pageTable.id, p.id)) + .get(); + expect(row?.customDomain).toBe(p.customDomain); + }); + }); + + test("allows an enabled domain and clearing it after the feature is disabled", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const p = await newPage({ + ctx, + input: { title: "Domain", slug: uniqueSlug("domain-clear") }, + }); + await updatePageCustomDomain({ + ctx, + input: { id: p.id, customDomain: "status.example.com" }, + }); + const row = await tx + .select() + .from(pageTable) + .where(eq(pageTable.id, p.id)) + .get(); + expect(row?.customDomain).toBe("status.example.com"); + await expectAuditRow({ + workspaceId: ctx.workspace.id, + action: "page.update", + entityType: "page", + entityId: p.id, + db: tx, + }); + await updatePageCustomDomain({ + ctx: { + ...ctx, + workspace: { + ...ctx.workspace, + limits: { ...ctx.workspace.limits, "custom-domain": false }, + }, + }, + input: { id: p.id, customDomain: "" }, + }); + const cleared = await tx + .select() + .from(pageTable) + .where(eq(pageTable.id, p.id)) + .get(); + expect(cleared?.customDomain).toBe(""); + }); + }); +}); + describe("updatePageConfiguration", () => { test("preserves omitted settings in partial updates", async () => { await withTestTransaction(async (tx) => { diff --git a/packages/services/src/page/update.ts b/packages/services/src/page/update.ts index 06115882..fd31784f 100644 --- a/packages/services/src/page/update.ts +++ b/packages/services/src/page/update.ts @@ -75,6 +75,10 @@ export async function updatePageCustomDomain(args: { requireScope(ctx, "write"); const input = UpdatePageCustomDomainInput.parse(args.input); + if (input.customDomain && !ctx.workspace.limits["custom-domain"]) { + throw new LimitExceededError("custom-domain", 0); + } + return withTransaction(ctx, async (tx) => { const existing = await getPageInWorkspace({ tx, -- 2.51.2