From 5ced1714b028ae5e423a4bc08dc0030c8d01e5ff Mon Sep 17 00:00:00 2001 From: MrMushrooooom Date: Sun, 30 Aug 2026 14:08:54 +0000 Subject: [PATCH] feat(console): add support workspace deletion --- .../api/support/actions/delete-workspace.ts | 32 +++ packages/console/core/src/key.ts | 17 +- packages/console/core/src/workspace.ts | 119 ++++++++++- packages/console/core/test/workspace.test.ts | 195 ++++++++++++++++++ 4 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 packages/console/app/src/routes/api/support/actions/delete-workspace.ts create mode 100644 packages/console/core/test/workspace.test.ts diff --git a/packages/console/app/src/routes/api/support/actions/delete-workspace.ts b/packages/console/app/src/routes/api/support/actions/delete-workspace.ts new file mode 100644 index 000000000000..1311790663b8 --- /dev/null +++ b/packages/console/app/src/routes/api/support/actions/delete-workspace.ts @@ -0,0 +1,32 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" +import { Resource } from "@opencode-ai/console-resource" +import z from "zod" + +const Body = z.object({ + workspaceID: z.string().startsWith("wrk_"), + requesterEmail: z.email(), +}) + +export async function DELETE(event: APIEvent) { + if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = Body.safeParse(await event.request.json().catch(() => undefined)) + if (!body.success) { + return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 }) + } + return Workspace.removeExact({ + workspaceID: body.data.workspaceID, + expectedRequesterEmail: body.data.requesterEmail, + }) + .then(() => Response.json({ success: true, message: "Workspace deleted" })) + .catch((error) => { + if (error instanceof Workspace.RemovalRejected) { + return Response.json({ error: error.message }, { status: 400 }) + } + return Response.json({ error: "Workspace deletion outcome is unknown" }, { status: 500 }) + }) +} diff --git a/packages/console/core/src/key.ts b/packages/console/core/src/key.ts index aef4298c90f2..a89dae6b8eb9 100644 --- a/packages/console/core/src/key.ts +++ b/packages/console/core/src/key.ts @@ -6,6 +6,7 @@ import { Identifier } from "./identifier" import { KeyTable } from "./schema/key.sql" import { UserTable } from "./schema/user.sql" import { AuthTable } from "./schema/auth.sql" +import { WorkspaceTable } from "./schema/workspace.sql" export namespace Key { export const list = fn(z.void(), async () => { @@ -57,16 +58,24 @@ export namespace Key { } const keyID = Identifier.create("key") - await Database.use((tx) => - tx.insert(KeyTable).values({ + await Database.transaction(async (tx) => { + const workspace = await tx + .select({ id: WorkspaceTable.id }) + .from(WorkspaceTable) + .where(and(eq(WorkspaceTable.id, Actor.workspace()), isNull(WorkspaceTable.timeDeleted))) + .for("update") + .then((rows) => rows[0]) + if (!workspace) throw new Error("Workspace is not active") + + await tx.insert(KeyTable).values({ id: keyID, workspaceID: Actor.workspace(), userID: input.userID, name, key: secretKey, timeUsed: null, - }), - ) + }) + }) return keyID }, diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index 00662bb5117e..a03594c620a7 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -4,13 +4,19 @@ import { Actor } from "./actor" import { Database } from "./drizzle" import { Identifier } from "./identifier" import { UserTable } from "./schema/user.sql" -import { BillingTable } from "./schema/billing.sql" +import { BillingTable, LiteTable, SubscriptionTable } from "./schema/billing.sql" import { WorkspaceTable } from "./schema/workspace.sql" import { AccountTable } from "./schema/account.sql" import { Key } from "./key" import { and, eq, isNull, sql } from "drizzle-orm" +import { AuthTable } from "./schema/auth.sql" +import { KeyTable } from "./schema/key.sql" +import { ProviderTable } from "./schema/provider.sql" +import { ModelTable } from "./schema/model.sql" export namespace Workspace { + export class RemovalRejected extends Error {} + export const Region = z.enum(["us", "eu", "sg", "cn"]) export type Region = z.infer @@ -104,6 +110,117 @@ export namespace Workspace { ) }) + export const removeExact = fn( + z.object({ + workspaceID: z.string().startsWith("wrk_"), + expectedRequesterEmail: z.email(), + }), + async (input) => { + await Database.transaction(async (tx) => { + const workspace = await tx + .select({ id: WorkspaceTable.id, timeDeleted: WorkspaceTable.timeDeleted }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, input.workspaceID)) + .for("update") + .then((rows) => rows[0]) + if (!workspace) throw new RemovalRejected("Workspace not found") + + const requester = await tx + .select({ + accountID: AccountTable.id, + role: UserTable.role, + invitationEmail: UserTable.email, + membershipDeleted: UserTable.timeDeleted, + }) + .from(AuthTable) + .innerJoin(AccountTable, and(eq(AccountTable.id, AuthTable.accountID), isNull(AccountTable.timeDeleted))) + .innerJoin( + UserTable, + and(eq(UserTable.accountID, AccountTable.id), eq(UserTable.workspaceID, input.workspaceID)), + ) + .where( + and( + eq(AuthTable.provider, "email"), + eq(AuthTable.subject, input.expectedRequesterEmail), + isNull(AuthTable.timeDeleted), + ), + ) + .for("update") + if (requester.length !== 1 || requester[0].role !== "admin" || requester[0].invitationEmail) { + throw new RemovalRejected("Expected requester is not an administrator of this workspace") + } + if (!workspace.timeDeleted && requester[0].membershipDeleted) { + throw new RemovalRejected("Expected requester does not have an active workspace membership") + } + if (workspace.timeDeleted) { + if (!requester[0].membershipDeleted) throw new RemovalRejected("Deleted workspace has an active membership") + return + } + + const billing = await tx + .select({ + timeDeleted: BillingTable.timeDeleted, + balance: BillingTable.balance, + reload: BillingTable.reload, + subscription: BillingTable.subscription, + subscriptionID: BillingTable.subscriptionID, + subscriptionPlan: BillingTable.subscriptionPlan, + timeSubscriptionBooked: BillingTable.timeSubscriptionBooked, + timeSubscriptionSelected: BillingTable.timeSubscriptionSelected, + liteSubscriptionID: BillingTable.liteSubscriptionID, + lite: BillingTable.lite, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, input.workspaceID)) + .for("update") + if (billing.length !== 1 || billing[0].timeDeleted) throw new RemovalRejected("Workspace billing state is inconsistent") + if (billing[0].balance > 0) throw new RemovalRejected("Workspace has a positive Zen balance") + if (billing[0].reload) throw new RemovalRejected("Workspace has Zen reload enabled") + if ( + billing[0].subscription || + billing[0].subscriptionID || + billing[0].subscriptionPlan || + billing[0].timeSubscriptionBooked || + billing[0].timeSubscriptionSelected + ) { + throw new RemovalRejected("Workspace has active or inconsistent Black billing state") + } + if (billing[0].liteSubscriptionID || billing[0].lite) { + throw new RemovalRejected("Workspace has active or inconsistent Go billing state") + } + + const black = await tx + .select({ id: SubscriptionTable.id }) + .from(SubscriptionTable) + .where(and(eq(SubscriptionTable.workspaceID, input.workspaceID), isNull(SubscriptionTable.timeDeleted))) + .for("update") + if (black.length > 0) throw new RemovalRejected("Workspace has active or inconsistent Black entitlement state") + const go = await tx + .select({ id: LiteTable.id }) + .from(LiteTable) + .where(and(eq(LiteTable.workspaceID, input.workspaceID), isNull(LiteTable.timeDeleted))) + .for("update") + if (go.length > 0) throw new RemovalRejected("Workspace has active or inconsistent Go entitlement state") + + const timeDeleted = new Date() + await tx + .update(WorkspaceTable) + .set({ timeDeleted }) + .where(and(eq(WorkspaceTable.id, input.workspaceID), isNull(WorkspaceTable.timeDeleted))) + await tx + .update(UserTable) + .set({ timeDeleted }) + .where(and(eq(UserTable.workspaceID, input.workspaceID), isNull(UserTable.timeDeleted))) + await tx + .update(KeyTable) + .set({ timeDeleted }) + .where(and(eq(KeyTable.workspaceID, input.workspaceID), isNull(KeyTable.timeDeleted))) + await tx.delete(ProviderTable).where(eq(ProviderTable.workspaceID, input.workspaceID)) + await tx.delete(ModelTable).where(eq(ModelTable.workspaceID, input.workspaceID)) + }) + }, + ) + export const unblock = fn(z.string().startsWith("wrk_"), async (workspaceID) => { await Database.transaction(async (tx) => { const workspace = await tx diff --git a/packages/console/core/test/workspace.test.ts b/packages/console/core/test/workspace.test.ts new file mode 100644 index 000000000000..219a9a1571cf --- /dev/null +++ b/packages/console/core/test/workspace.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { and, eq, getTableName, isNull, sql } from "drizzle-orm" +import type { SQLWrapper, Table } from "drizzle-orm" +import { MySqlDialect } from "drizzle-orm/mysql-core" + +type Row = Record + +class Query { + constructor(private rows: Row[]) {} + innerJoin() { + return this + } + where() { + return this + } + for() { + return this + } + then(resolve: (rows: Row[]) => unknown) { + return Promise.resolve(this.rows).then(resolve) + } +} + +class Update { + private values: Row = {} + + constructor( + private database: TestDatabase, + private table: Table, + ) {} + + set(values: Row) { + this.values = values + return this + } + + where(condition: SQLWrapper) { + const table = getTableName(this.table) + const query = new MySqlDialect().sqlToQuery(condition.getSQL()) + const workspaceID = query.params.find((param) => typeof param === "string" && param.startsWith("wrk_")) + const rows = table === "workspace" ? this.database.workspaces : this.database.rows[table] + rows?.forEach((row) => { + const matches = table === "workspace" ? row.id === workspaceID : row.workspaceID === workspaceID + if (matches && !row.timeDeleted) Object.assign(row, this.values) + }) + if (table === "user") Object.assign(this.database.requester[0], { membershipDeleted: this.values.timeDeleted }) + this.database.updated.push(table) + return Promise.resolve() + } +} + +class Delete { + constructor( + private database: TestDatabase, + private table: Table, + ) {} + + where(condition: SQLWrapper) { + const table = getTableName(this.table) + const query = new MySqlDialect().sqlToQuery(condition.getSQL()) + const workspaceID = query.params.find((param) => typeof param === "string" && param.startsWith("wrk_")) + this.database.rows[table] = this.database.rows[table]?.filter((row) => row.workspaceID !== workspaceID) ?? [] + this.database.updated.push(`delete:${table}`) + return Promise.resolve() + } +} + +class TestDatabase { + workspaces: Row[] = [ + { id: target, timeDeleted: null }, + { id: other, timeDeleted: null }, + ] + rows: Record = { + user: [ + { id: "usr_target", workspaceID: target, timeDeleted: null }, + { id: "usr_other", workspaceID: other, timeDeleted: null }, + ], + key: [ + { id: "key_target", workspaceID: target, timeDeleted: null }, + { id: "key_other", workspaceID: other, timeDeleted: null }, + ], + provider: [ + { id: "provider_target", workspaceID: target, timeDeleted: null }, + { id: "provider_other", workspaceID: other, timeDeleted: null }, + ], + model: [ + { id: "model_target", workspaceID: target, timeDeleted: null }, + { id: "model_other", workspaceID: other, timeDeleted: null }, + ], + } + billing: Row = { + timeDeleted: null, + balance: 0, + reload: false, + subscription: null, + subscriptionID: null, + subscriptionPlan: null, + timeSubscriptionBooked: null, + timeSubscriptionSelected: null, + liteSubscriptionID: null, + lite: null, + } + requester: Row[] = [{ accountID: "acc_target", role: "admin", invitationEmail: null, membershipDeleted: null }] + black: Row[] = [] + go: Row[] = [] + updated: string[] = [] + + select() { + return { + from: (table: Table) => { + const name = getTableName(table) + if (name === "workspace") return new Query(this.workspaces.filter((row) => row.id === target)) + if (name === "auth") return new Query(this.requester) + if (name === "billing") return new Query([this.billing]) + if (name === "subscription") return new Query(this.black) + if (name === "lite") return new Query(this.go) + throw new Error(`Unexpected select from ${name}`) + }, + } + } + + update(table: Table) { + return new Update(this, table) + } + + delete(table: Table) { + return new Delete(this, table) + } +} + +const target = "wrk_target" +const other = "wrk_other" +let database = new TestDatabase() + +mock.module("../src/drizzle", () => ({ + and, + Database: { + transaction: (callback: (tx: TestDatabase) => Promise) => callback(database), + }, + eq, + isNull, + sql, +})) + +const { Workspace } = await import("../src/workspace") + +beforeEach(() => { + database = new TestDatabase() +}) + +describe("Workspace.removeExact", () => { + test("deletes only the exact workspace and revokes its memberships and keys", async () => { + await Workspace.removeExact({ workspaceID: target, expectedRequesterEmail: "owner@example.com" }) + + expect(database.workspaces.find((row) => row.id === target)?.timeDeleted).toBeInstanceOf(Date) + expect(database.rows.user.find((row) => row.workspaceID === target)?.timeDeleted).toBeInstanceOf(Date) + expect(database.rows.key.find((row) => row.workspaceID === target)?.timeDeleted).toBeInstanceOf(Date) + expect(database.workspaces.find((row) => row.id === other)?.timeDeleted).toBeNull() + expect(database.rows.user.find((row) => row.workspaceID === other)?.timeDeleted).toBeNull() + expect(database.rows.key.find((row) => row.workspaceID === other)?.timeDeleted).toBeNull() + expect(database.rows.provider.some((row) => row.workspaceID === target)).toBe(false) + expect(database.rows.model.some((row) => row.workspaceID === target)).toBe(false) + expect(database.rows.provider.some((row) => row.workspaceID === other)).toBe(true) + expect(database.rows.model.some((row) => row.workspaceID === other)).toBe(true) + expect(database.updated).toEqual(["workspace", "user", "key", "delete:provider", "delete:model"]) + }) + + test("allows an exact retry only when the deleted admin membership still proves ownership", async () => { + await Workspace.removeExact({ workspaceID: target, expectedRequesterEmail: "owner@example.com" }) + database.updated = [] + + await Workspace.removeExact({ workspaceID: target, expectedRequesterEmail: "owner@example.com" }) + + expect(database.updated).toEqual([]) + }) + + test.each([ + ["non-admin requester", () => (database.requester[0].role = "member")], + ["invitation-only requester", () => (database.requester = [])], + ["inconsistent accepted invitation", () => (database.requester[0].invitationEmail = "owner@example.com")], + ["positive Zen balance", () => (database.billing.balance = 1)], + ["Zen reload", () => (database.billing.reload = true)], + ["Black billing", () => (database.billing.subscriptionID = "sub_active")], + ["Go billing", () => (database.billing.liteSubscriptionID = "sub_active")], + ["Black entitlement", () => (database.black = [{ id: "sub_black" }])], + ["Go entitlement", () => (database.go = [{ id: "sub_go" }])], + ])("rejects %s without deleting data", async (_name, arrange) => { + arrange() + + expect( + Workspace.removeExact({ workspaceID: target, expectedRequesterEmail: "owner@example.com" }), + ).rejects.toBeTruthy() + expect(database.updated).toEqual([]) + }) +})