Compare commits

...
Author SHA1 Message Date
MrMushrooooom 5ced1714b0 feat(console): add support workspace deletion 2026-08-30 14:08:54 +00:00
4 changed files with 358 additions and 5 deletions

No files matched your search

@@ -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 })
})
}
+13 -4
View File
@@ -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
},
+118 -1
View File
@@ -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<typeof Region>
@@ -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
@@ -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<string, unknown>
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<string, Row[]> = {
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<unknown>) => 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([])
})
})