Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 })
})
}
17 changes: 13 additions & 4 deletions packages/console/core/src/key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
},
Expand Down
119 changes: 118 additions & 1 deletion packages/console/core/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading