From f72dbbf4263cf12f3cec14e03069ea482b5eeac2 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 6 Oct 2026 09:48:20 +0200 Subject: [PATCH] fix: --- .../routes/rpc/handlers/status-page/index.ts | 22 +-- .../api/internal/domains/reconcile/route.ts | 11 +- packages/api/src/router/user.ts | 7 +- .../src/page/__tests__/domain-sync.test.ts | 4 + .../page/__tests__/set-custom-domain.test.ts | 136 ++++++++++++++---- packages/services/src/page/create.ts | 3 + packages/services/src/page/delete.ts | 74 ++++++---- packages/services/src/page/domain-sync.ts | 54 +++++-- packages/services/src/page/list.ts | 12 +- .../services/src/page/set-custom-domain.ts | 31 +++- packages/services/src/user/delete.ts | 27 ++-- packages/services/src/workspace/downgrade.ts | 6 +- 12 files changed, 271 insertions(+), 116 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 31ef1f62f..719f52889 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -44,6 +44,7 @@ import { import { createPage, deletePage, + detachDomainIfUnused, getPage, getPageBySlug, getStatusPageContent, @@ -651,16 +652,17 @@ export const statusPageServiceImpl: ServiceImpl = { input: { id: created.id, customDomain }, }); } catch (err) { - await deletePage({ - ctx: sCtx, - input: { id: created.id }, - releaseDomain: false, - }).catch((cleanupErr) => + // The attach may have landed before the failure; release it once + // the page is gone (skipped if another page holds the domain). + try { + await deletePage({ ctx: sCtx, input: { id: created.id } }); + await detachDomainIfUnused({ domain: customDomain }); + } catch (cleanupErr) { console.error("Failed to roll back status page create:", { pageId: created.id, error: cleanupErr, - }), - ); + }); + } throw err; } } @@ -879,7 +881,8 @@ export const statusPageServiceImpl: ServiceImpl = { } // Outside the transaction below: Vercel calls must not hold the libSQL - // writer. First, so an attach failure aborts before any other write. + // writer. First, so an attach failure aborts before any other write; a + // later section failure leaves the domain change applied. if (customDomainForUpdate !== undefined) { await setPageCustomDomain({ ctx: sCtx, @@ -887,8 +890,7 @@ export const statusPageServiceImpl: ServiceImpl = { }); } - // Wrap all per-section updates in a single transaction so partial - // failures don't leave the page in a half-updated state. Each + // Wrap the remaining per-section updates in a single transaction. Each // per-section service call's internal `withTransaction` detects // the pre-opened tx and skips nesting. await withTransaction(sCtx, async (tx) => { 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 495a5a402..9044b9ffe 100644 --- a/apps/web/src/app/api/internal/domains/reconcile/route.ts +++ b/apps/web/src/app/api/internal/domains/reconcile/route.ts @@ -7,8 +7,9 @@ import { captureMessage } from "@sentry/nextjs"; import type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { + const secret = process.env.CRON_SECRET; const authHeader = request.headers.get("authorization"); - if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + if (!secret || authHeader !== `Bearer ${secret}`) { return new Response("Unauthorized", { status: 401 }); } @@ -21,11 +22,15 @@ export async function GET(request: NextRequest) { apply: process.env.DOMAIN_RECONCILE_APPLY === "1", }); - if (result.orphans.length > 0 || result.missing.length > 0) { + const remaining = { + orphans: result.orphans.filter((d) => !result.detached.includes(d)), + missing: result.missing, + }; + if (remaining.orphans.length > 0 || remaining.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, + extra: { ...result, remaining }, }); } diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index ee8e8970d..66811058e 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -39,8 +39,11 @@ export const userRouter = createTRPCRouter({ }); // `userId` is derived from `ctx.actor` inside the service — no // input needed. - await deleteAccount({ ctx: toServiceCtx(ctx) }); - for (const domain of customDomains) { + const deleted = await deleteAccount({ ctx: toServiceCtx(ctx) }); + for (const domain of new Set([ + ...customDomains, + ...deleted.customDomains, + ])) { await detachDomainIfUnused({ db: ctx.db, domain }).catch((error) => console.error("Failed to release domain from Vercel:", { domain, diff --git a/packages/services/src/page/__tests__/domain-sync.test.ts b/packages/services/src/page/__tests__/domain-sync.test.ts index 4342edf6f..b38e079e7 100644 --- a/packages/services/src/page/__tests__/domain-sync.test.ts +++ b/packages/services/src/page/__tests__/domain-sync.test.ts @@ -199,7 +199,11 @@ for (const path of [ "//evil.example/v2/user", ]) { test(`vercelFetch refuses ${path}`, async () => { + mockVercel(() => { + throw new Error("unexpected network call"); + }); const error = await vercelFetch("token", path).catch((e) => e); expect(error).toBeInstanceOf(ValidationError); + expect(calls).toHaveLength(0); }); } 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 edda6380d..35ef1323d 100644 --- a/packages/services/src/page/__tests__/set-custom-domain.test.ts +++ b/packages/services/src/page/__tests__/set-custom-domain.test.ts @@ -1,22 +1,30 @@ -import { eq } from "@openstatus/db"; +import { db, 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 { + afterAll, + afterEach, + beforeAll, + describe, + test, +} from "@std/testing/bdd"; import { + clearAuditLogFor, createWorkspaceFixture, makeApiKeyCtx, makeUserCtx, withTestTransaction, } from "../../../test/helpers"; -import type { DrizzleTx, ServiceContext } from "../../context"; +import type { DB, ServiceContext } from "../../context"; import { + ConflictError, ForbiddenError, InternalServiceError, LimitExceededError, ValidationError, } from "../../errors"; -import { newPage } from "../create"; +import { createPage, newPage } from "../create"; import { deletePage } from "../delete"; import type { VercelDomainConfig } from "../domain-sync"; import { setPageCustomDomain } from "../set-custom-domain"; @@ -63,7 +71,7 @@ beforeAll(async () => { }); async function pageWithDomain( - tx: DrizzleTx, + tx: DB, ctx: ServiceContext, customDomain: string, ) { @@ -81,7 +89,7 @@ async function pageWithDomain( return p.id; } -async function storedDomain(tx: DrizzleTx, id: number) { +async function storedDomain(tx: DB, id: number) { const row = await tx .select({ customDomain: pageTable.customDomain }) .from(pageTable) @@ -135,11 +143,42 @@ describe("setPageCustomDomain", () => { ); await setPageCustomDomain({ ctx, input: { id, customDomain: next } }); expect(calls.map((c) => c.method)).toEqual(["POST", "GET", "DELETE"]); + expect(calls[1].url.pathname).toContain(encodeURIComponent(next)); + expect(calls[2].url.pathname).toContain(encodeURIComponent(old)); expect(await storedDomain(tx, id)).toBe(next); }); }); - test("an unchanged save makes no Vercel call", async () => { + test("rejects a domain another page already holds", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const taken = domain(); + await pageWithDomain(tx, freeCtx, taken); + const id = await pageWithDomain(tx, teamCtx, ""); + mockVercel(ok); + await expect( + setPageCustomDomain({ + ctx, + input: { id, customDomain: taken.toUpperCase() }, + }), + ).rejects.toBeInstanceOf(ConflictError); + expect(calls).toHaveLength(0); + expect(await storedDomain(tx, id)).toBe(""); + }); + }); + + test("an unchanged domain on a plan without the feature is not rejected", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...freeCtx, db: tx }; + const kept = domain(); + const id = await pageWithDomain(tx, freeCtx, kept); + mockVercel(ok); + await setPageCustomDomain({ ctx, input: { id, customDomain: kept } }); + expect(calls.map((c) => c.method)).toEqual(["POST"]); + }); + }); + + test("an unchanged save only re-attaches, writing nothing", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const current = domain(); @@ -149,6 +188,17 @@ describe("setPageCustomDomain", () => { ctx, input: { id, customDomain: current.toUpperCase() }, }); + expect(calls.map((c) => c.method)).toEqual(["POST"]); + expect(await storedDomain(tx, id)).toBe(current); + }); + }); + + test("an unchanged empty domain makes no Vercel call", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const id = await pageWithDomain(tx, teamCtx, ""); + mockVercel(ok); + await setPageCustomDomain({ ctx, input: { id, customDomain: "" } }); expect(calls).toHaveLength(0); }); }); @@ -191,37 +241,67 @@ describe("setPageCustomDomain", () => { }); }); +test("createPage never writes a custom domain", 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(""); + expect(calls).toHaveLength(0); + }); +}); + 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)) { + await db.delete(pageTable).where(eq(pageTable.id, id)); + } + }); + 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; + } + 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(); - }); + const id = await committedPage(domain()); + mockVercel(ok); + await deletePage({ ctx: teamCtx, input: { id } }); + expect(calls.map((c) => c.method)).toEqual(["DELETE"]); + expect(await storedDomain(db, 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); - }); + const current = domain(); + const id = await committedPage(current); + mockVercel(status(500)); + await expect( + deletePage({ ctx: teamCtx, input: { id } }), + ).rejects.toBeInstanceOf(InternalServiceError); + expect(await storedDomain(db, id)).toBe(current); }); - test("releaseDomain: false makes no call", async () => { + test("skips Vercel inside a caller's transaction", 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 }); + await deletePage({ ctx: { ...teamCtx, db: tx }, input: { id } }); expect(calls).toHaveLength(0); expect(await storedDomain(tx, id)).toBeUndefined(); }); diff --git a/packages/services/src/page/create.ts b/packages/services/src/page/create.ts index 0f9958f9d..6e148a785 100644 --- a/packages/services/src/page/create.ts +++ b/packages/services/src/page/create.ts @@ -70,6 +70,9 @@ 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/delete.ts b/packages/services/src/page/delete.ts index cfac7bfd6..b3ecc3c89 100644 --- a/packages/services/src/page/delete.ts +++ b/packages/services/src/page/delete.ts @@ -3,8 +3,17 @@ import { page } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; -import { getReadDb, type ServiceContext, withTransaction } from "../context"; -import { detachDomainIfUnused, resolveVercelConfig } from "./domain-sync"; +import { + getReadDb, + isTx, + type ServiceContext, + withTransaction, +} from "../context"; +import { + attachDomain, + detachDomainIfUnused, + resolveVercelConfig, +} from "./domain-sync"; import { getPageInWorkspace } from "./internal"; import { getPageCustomDomain } from "./list"; import { DeletePageInput } from "./schemas"; @@ -12,44 +21,61 @@ import { DeletePageInput } from "./schemas"; /** * 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. + * delete and stays retryable. Inside a caller's transaction it skips Vercel — + * HTTP must not hold the libSQL writer — and the caller releases after commit. */ export async function deletePage(args: { ctx: ServiceContext; input: DeletePageInput; - releaseDomain?: boolean; }): Promise { - const { ctx, releaseDomain = true } = args; + const { ctx } = args; requireScope(ctx, "write"); const input = DeletePageInput.parse(args.input); - if (releaseDomain) { + const config = resolveVercelConfig(ctx); + let detached: string | null = null; + if (!ctx.db || !isTx(ctx.db)) { const domain = await getPageCustomDomain({ ctx, input: { id: input.id } }); - if (domain) { - await detachDomainIfUnused({ + if ( + domain && + (await detachDomainIfUnused({ db: getReadDb(ctx), domain, excludePageId: input.id, - config: resolveVercelConfig(ctx), - }); + config, + })) + ) { + detached = domain; } } - await withTransaction(ctx, async (tx) => { - const existing = await getPageInWorkspace({ - tx, - id: input.id, - workspaceId: ctx.workspace.id, - }); + try { + await withTransaction(ctx, async (tx) => { + const existing = await getPageInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); - await tx.delete(page).where(eq(page.id, existing.id)); + await tx.delete(page).where(eq(page.id, existing.id)); - await emitAudit(tx, ctx, { - action: "page.delete", - entityType: "page", - entityId: existing.id, - before: existing, + await emitAudit(tx, ctx, { + action: "page.delete", + entityType: "page", + entityId: existing.id, + before: existing, + }); }); - }); + } catch (err) { + // The page survives, so give its domain back rather than leave it unrouted. + if (detached) { + await attachDomain(config, detached).catch((attachErr) => + console.warn("Failed to re-attach domain after a failed delete:", { + domain: detached, + error: attachErr, + }), + ); + } + throw err; + } } diff --git a/packages/services/src/page/domain-sync.ts b/packages/services/src/page/domain-sync.ts index cad0fdd41..ec1dd7ddf 100644 --- a/packages/services/src/page/domain-sync.ts +++ b/packages/services/src/page/domain-sync.ts @@ -1,4 +1,4 @@ -import { and, ne, sql } from "@openstatus/db"; +import { and, db as defaultDb, ne, sql } from "@openstatus/db"; import { page } from "@openstatus/db/src/schema"; import type { DB, ServiceContext } from "../context"; @@ -86,6 +86,8 @@ 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."; +const READ_FAILED = + "Failed to check custom domain. Please try again. If it continues, contact support."; export type ProjectDomain = { name: string; @@ -99,13 +101,13 @@ export async function getProjectDomain( domain: string, ): Promise { const response = await syncFetch(config, projectDomainPath(config, domain), { - failure: ADD_FAILED, + failure: READ_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); + throw new InternalServiceError(READ_FAILED, error); } return response.json(); } @@ -150,18 +152,13 @@ export async function detachDomain( 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: { +/** Any page (across workspaces) holding `domain`, case-insensitive. */ +export async function findDomainHolder(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 +}): Promise<{ id: number } | undefined> { + return args.db .select({ id: page.id }) .from(page) .where( @@ -173,16 +170,35 @@ export async function detachDomainIfUnused(args: { ), ) .get(); +} +// customDomain has no unique constraint, so another workspace's page may +// hold the same domain — detaching it would take their status page down. +// Returns whether the domain was detached. +export async function detachDomainIfUnused(args: { + db?: DB; + domain: string; + excludePageId?: number; + config?: VercelDomainConfig | null; +}): Promise { + const config = resolveVercelConfig({ vercel: args.config }); + if (!config) return false; + + const holder = await findDomainHolder({ + db: args.db ?? defaultDb, + domain: args.domain, + excludePageId: args.excludePageId, + }); if (holder) { console.warn("Skipping Vercel domain removal, still in use:", { domain: args.domain, pageId: holder.id, }); - return; + return false; } await detachDomain(config, args.domain); + return true; } export async function listProjectDomains( @@ -271,8 +287,16 @@ export async function reconcileProjectDomains(args: { if (args.apply) { for (const domain of orphans) { try { - await detachDomain(args.config, domain); - detached.push(domain); + // Re-check: a concurrent set attaches on Vercel before writing the row. + if ( + await detachDomainIfUnused({ + db: args.db, + domain, + config: args.config, + }) + ) { + detached.push(domain); + } } catch (err) { console.warn("Failed to detach orphaned domain:", { domain, diff --git a/packages/services/src/page/list.ts b/packages/services/src/page/list.ts index 5551d6127..c62cf0aab 100644 --- a/packages/services/src/page/list.ts +++ b/packages/services/src/page/list.ts @@ -138,17 +138,7 @@ export async function getPage(args: { }; } -/** - * Narrow "just the customDomain" read, scoped to the caller's workspace. - * - * The tRPC `updateCustomDomain` procedure needs the *pre-update* domain - * so it can call Vercel add/remove before the db write — it can't use - * the `existingDomain` returned from `updatePageCustomDomain` because - * Vercel needs the old value up front. Using `getPage` (the full- - * relations read) here would fire 3 extra batched queries for - * maintenances / pageComponents / pageComponentGroups we don't need. - * This one-column select replaces that with a single indexed lookup. - */ +/** One-column read of the page's custom domain, scoped to the workspace. */ export async function getPageCustomDomain(args: { ctx: ServiceContext; input: GetPageInput; diff --git a/packages/services/src/page/set-custom-domain.ts b/packages/services/src/page/set-custom-domain.ts index 878665001..b2bb32afa 100644 --- a/packages/services/src/page/set-custom-domain.ts +++ b/packages/services/src/page/set-custom-domain.ts @@ -1,9 +1,10 @@ import { requireScope } from "../auth"; import { getReadDb, type ServiceContext } from "../context"; -import { LimitExceededError, ValidationError } from "../errors"; +import { ConflictError, LimitExceededError, ValidationError } from "../errors"; import { attachDomain, detachDomainIfUnused, + findDomainHolder, resolveVercelConfig, } from "./domain-sync"; import { getPageCustomDomain } from "./list"; @@ -25,18 +26,36 @@ export async function setPageCustomDomain(args: { const input = UpdatePageCustomDomainInput.parse(args.input); const next = input.customDomain; + const previous = await getPageCustomDomain({ ctx, input: { id: input.id } }); + const config = resolveVercelConfig(ctx); + // Case-only changes count as unchanged: DNS is case-insensitive. Re-attaching + // (idempotent) lets a re-save heal a row that never reached Vercel. + if (previous.toLowerCase() === next.toLowerCase()) { + if (previous) await attachDomain(config, previous); + return; + } + 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'"); } + // Attach is idempotent per project, so it can't tell our own page's + // attachment from another workspace's — the row check has to. + if ( + next && + (await findDomainHolder({ + db: getReadDb(ctx), + domain: next, + excludePageId: input.id, + })) + ) { + throw new ConflictError( + `The domain '${next}' is already in use. Remove it there first or contact support.`, + ); + } - 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({ diff --git a/packages/services/src/user/delete.ts b/packages/services/src/user/delete.ts index b04963fe9..ab7480727 100644 --- a/packages/services/src/user/delete.ts +++ b/packages/services/src/user/delete.ts @@ -47,12 +47,13 @@ import { DeleteAccountInput } from "./schemas"; * 5. Blanks out PII on the user row and stamps `deletedAt`. * * All writes run in a single transaction so a partial failure never - * leaves the account half-deleted. + * leaves the account half-deleted. Returns the deleted pages' custom domains + * for the caller to release on Vercel after commit. */ export async function deleteAccount(args: { ctx: ServiceContext; input?: DeleteAccountInput; -}): Promise { +}): Promise<{ customDomains: string[] }> { const { ctx } = args; requireScope(ctx, "write"); if (args.input !== undefined) DeleteAccountInput.parse(args.input); @@ -68,7 +69,8 @@ export async function deleteAccount(args: { ); } - await withTransaction(ctx, async (tx) => { + return withTransaction(ctx, async (tx) => { + const customDomains = new Set(); const existing = await tx.query.user.findFirst({ where: eq(user.id, userId), }); @@ -144,15 +146,14 @@ export async function deleteAccount(args: { await deleteMonitors({ ctx: subCtx, input: { ids: monitorIds } }); } - const pageIds = ( - await tx - .select({ id: page.id }) - .from(page) - .where(eq(page.workspaceId, rawWorkspace.id)) - .all() - ).map((p) => p.id); - for (const id of pageIds) { - await deletePage({ ctx: subCtx, input: { id }, releaseDomain: false }); + const pages = await tx + .select({ id: page.id, customDomain: page.customDomain }) + .from(page) + .where(eq(page.workspaceId, rawWorkspace.id)) + .all(); + for (const p of pages) { + await deletePage({ ctx: subCtx, input: { id: p.id } }); + if (p.customDomain) customDomains.add(p.customDomain); } const notificationIds = ( @@ -213,5 +214,7 @@ export async function deleteAccount(args: { entityId: userId, before: existing, }); + + return { customDomains: [...customDomains] }; }); } diff --git a/packages/services/src/workspace/downgrade.ts b/packages/services/src/workspace/downgrade.ts index aad5499e8..20b6cd6e8 100644 --- a/packages/services/src/workspace/downgrade.ts +++ b/packages/services/src/workspace/downgrade.ts @@ -142,11 +142,7 @@ export async function downgradeWorkspaceToFree(args: { ]; for (const p of statusPages.slice(1)) { - await deletePage({ - ctx: txCtx, - input: { id: p.id }, - releaseDomain: false, - }); + await deletePage({ ctx: txCtx, input: { id: p.id } }); } // Strip the surviving page's paid-only access features. Both verbs -- 2.51.2