Sfoglia il codice sorgente

feat(console): add support actions (#33492)

Victor Navarro 2 mesi fa
parent
commit
ad3651d8f1

+ 1 - 0
infra/console.ts

@@ -256,6 +256,7 @@ new sst.cloudflare.x.SolidStart("Console", {
     SECRET.UpstashRedisRestToken,
     AUTH_API_URL,
     STRIPE_WEBHOOK_SECRET,
+    SECRET.SupportApiKey,
     DISCORD_INCIDENT_WEBHOOK_URL,
     SECRET.HoneycombWebhookSecret,
     STRIPE_SECRET_KEY,

+ 1 - 0
infra/enterprise.ts

@@ -7,6 +7,7 @@ new sst.cloudflare.x.SolidStart("Teams", {
   domain: shortDomain,
   path: "packages/enterprise",
   buildCommand: "bun run build:cloudflare",
+  link: [SECRET.SupportApiKey],
   environment: {
     OPENCODE_STORAGE_ADAPTER: "r2",
     OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,

+ 1 - 0
infra/secret.ts

@@ -9,6 +9,7 @@ export const SECRET = {
   R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
   HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
   HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
+  SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
   UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
   UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
 }

+ 26 - 0
packages/console/app/src/routes/api/support/actions/create-referral.ts

@@ -0,0 +1,26 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { Referral } from "@opencode-ai/console-core/referral.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({
+  inviterWorkspaceID: z.string().startsWith("wrk_"),
+  inviteeWorkspaceID: z.string().startsWith("wrk_"),
+})
+
+export async function POST(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 Referral.create(body.data)
+    .then((result) => Response.json({ success: true, message: "Referral created", result }))
+    .catch((error) =>
+      Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }),
+    )
+}

+ 23 - 0
packages/console/app/src/routes/api/support/actions/delete-account.ts

@@ -0,0 +1,23 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { Account } from "@opencode-ai/console-core/account.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({ email: 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 Account.remove(body.data.email)
+    .then(() => Response.json({ success: true, message: "Account deleted" }))
+    .catch((error) =>
+      Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 }),
+    )
+}

+ 0 - 1
packages/console/core/package.json

@@ -37,7 +37,6 @@
     "update-limits": "script/update-limits.ts",
     "promote-limits-to-dev": "script/promote-limits.ts dev",
     "promote-limits-to-prod": "script/promote-limits.ts production",
-    "referral-backfill": "script/referral-backfill.ts",
     "typecheck": "tsgo --noEmit"
   },
   "devDependencies": {

+ 0 - 153
packages/console/core/script/referral-backfill.ts

@@ -1,153 +0,0 @@
-import { and, Database, eq, inArray, isNull } from "../src/drizzle/index.js"
-import { Identifier } from "../src/identifier.js"
-import { Referral } from "../src/referral.js"
-import { LiteTable } from "../src/schema/billing.sql.js"
-import { ReferralRewardTable, ReferralTable } from "../src/schema/referral.sql.js"
-import { UserTable } from "../src/schema/user.sql.js"
-import { WorkspaceTable } from "../src/schema/workspace.sql.js"
-
-const backfills = [
-  {
-    inviterWorkspaceID: "wrk_00000000000000000000000000",
-    inviteeWorkspaceID: "wrk_00000000000000000000000000",
-    inviteeAccountID: "acc_00000000000000000000000000",
-  },
-]
-
-console.log(`Backfilling ${backfills.length} referrals`)
-
-for (const [index, backfill] of backfills.entries()) {
-  console.log(`[${index + 1}/${backfills.length}] ${backfill.inviterWorkspaceID} -> ${backfill.inviteeWorkspaceID}`)
-  console.log(`  invitee account: ${backfill.inviteeAccountID}`)
-
-  const result = await Database.transaction(async (tx) => {
-    if (backfill.inviterWorkspaceID === backfill.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
-
-    const inviterWorkspace = await tx
-      .select({ id: WorkspaceTable.id })
-      .from(WorkspaceTable)
-      .where(and(eq(WorkspaceTable.id, backfill.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
-      .then((rows) => rows[0])
-    if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${backfill.inviterWorkspaceID}`)
-
-    const inviteeWorkspace = await tx
-      .select({ id: WorkspaceTable.id })
-      .from(WorkspaceTable)
-      .where(and(eq(WorkspaceTable.id, backfill.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
-      .then((rows) => rows[0])
-    if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${backfill.inviteeWorkspaceID}`)
-
-    const inviteeUser = await tx
-      .select({ id: UserTable.id })
-      .from(UserTable)
-      .where(
-        and(
-          eq(UserTable.workspaceID, backfill.inviteeWorkspaceID),
-          eq(UserTable.accountID, backfill.inviteeAccountID),
-          eq(UserTable.role, "admin"),
-          isNull(UserTable.timeDeleted),
-        ),
-      )
-      .then((rows) => rows[0])
-    if (!inviteeUser) throw new Error(`Invitee workspace owner not found: ${backfill.inviteeAccountID}`)
-
-    const inviterUser = await tx
-      .select({ id: UserTable.id })
-      .from(UserTable)
-      .where(
-        and(
-          eq(UserTable.workspaceID, backfill.inviterWorkspaceID),
-          eq(UserTable.accountID, backfill.inviteeAccountID),
-          isNull(UserTable.timeDeleted),
-        ),
-      )
-      .then((rows) => rows[0])
-    if (inviterUser) throw new Error(`Self-referral is not allowed: ${backfill.inviteeAccountID}`)
-
-    const lite = await tx
-      .select({ id: LiteTable.id })
-      .from(LiteTable)
-      .where(
-        and(
-          eq(LiteTable.workspaceID, backfill.inviteeWorkspaceID),
-          eq(LiteTable.userID, inviteeUser.id),
-          isNull(LiteTable.timeDeleted),
-        ),
-      )
-      .then((rows) => rows[0])
-    if (!lite) throw new Error(`Invitee Lite subscription not found: ${backfill.inviteeWorkspaceID}`)
-
-    const existingReferral = await tx
-      .select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
-      .from(ReferralTable)
-      .where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
-      .then((rows) => rows[0])
-    if (existingReferral && existingReferral.workspaceID !== backfill.inviterWorkspaceID) {
-      throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
-    }
-
-    const referralID = existingReferral?.id ?? Identifier.create("referral")
-    if (!existingReferral) {
-      await tx.insert(ReferralTable).ignore().values({
-        workspaceID: backfill.inviterWorkspaceID,
-        id: referralID,
-        inviteeAccountID: backfill.inviteeAccountID,
-      })
-
-      const referral = await tx
-        .select({ id: ReferralTable.id })
-        .from(ReferralTable)
-        .where(and(eq(ReferralTable.inviteeAccountID, backfill.inviteeAccountID), isNull(ReferralTable.timeDeleted)))
-        .then((rows) => rows[0])
-      if (!referral) throw new Error(`Referral not created: ${backfill.inviteeAccountID}`)
-      if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
-    }
-
-    const rewardInsert = await tx
-      .insert(ReferralRewardTable)
-      .ignore()
-      .values([
-        {
-          workspaceID: backfill.inviterWorkspaceID,
-          referralID,
-          amount: Referral.REWARD_AMOUNT,
-        },
-        {
-          workspaceID: backfill.inviteeWorkspaceID,
-          referralID,
-          amount: Referral.REWARD_AMOUNT,
-        },
-      ])
-
-    const rewards = await tx
-      .select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
-      .from(ReferralRewardTable)
-      .where(
-        and(
-          eq(ReferralRewardTable.referralID, referralID),
-          inArray(ReferralRewardTable.workspaceID, [backfill.inviterWorkspaceID, backfill.inviteeWorkspaceID]),
-          isNull(ReferralRewardTable.timeDeleted),
-        ),
-      )
-    if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
-    if (rewards.some((reward) => reward.amount !== Referral.REWARD_AMOUNT)) {
-      throw new Error(`Referral reward amount mismatch: ${referralID}`)
-    }
-
-    return {
-      referralID,
-      createdReferral: !existingReferral,
-      createdRewards: rewardInsert.rowsAffected,
-      inviteeUserID: inviteeUser.id,
-      liteID: lite.id,
-      rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
-    }
-  })
-
-  console.log(`  invitee user: ${result.inviteeUserID}`)
-  console.log(`  lite: ${result.liteID}`)
-  console.log(`  referral: ${result.referralID} (${result.createdReferral ? "created" : "existing"})`)
-  console.log(`  rewards: ${result.rewardWorkspaces.join(", ")} (${result.createdRewards} inserted)`)
-}
-
-console.log("Referral backfill complete")

+ 41 - 1
packages/console/core/src/account.ts

@@ -1,9 +1,13 @@
 import { z } from "zod"
-import { eq } from "drizzle-orm"
+import { and, eq, inArray, sql } from "drizzle-orm"
 import { fn } from "./util/fn"
 import { Database } from "./drizzle"
 import { Identifier } from "./identifier"
 import { AccountTable } from "./schema/account.sql"
+import { AuthTable } from "./schema/auth.sql"
+import { UserTable } from "./schema/user.sql"
+import { KeyTable } from "./schema/key.sql"
+import { CouponTable } from "./schema/billing.sql"
 
 export namespace Account {
   export const create = fn(
@@ -20,6 +24,42 @@ export namespace Account {
       }),
   )
 
+  export const remove = fn(z.email(), async (email) => {
+    await Database.transaction(async (tx) => {
+      const account = await tx
+        .select({ id: AccountTable.id })
+        .from(AuthTable)
+        .innerJoin(AccountTable, eq(AccountTable.id, AuthTable.accountID))
+        .where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
+        .then((rows) => rows[0])
+      if (!account) throw new Error("Account not found")
+
+      const emails = await tx
+        .select({ email: AuthTable.subject })
+        .from(AuthTable)
+        .where(and(eq(AuthTable.accountID, account.id), eq(AuthTable.provider, "email")))
+      const users = await tx
+        .select({ id: UserTable.id })
+        .from(UserTable)
+        .where(eq(UserTable.accountID, account.id))
+      if (users.length > 0) {
+        await tx
+          .update(KeyTable)
+          .set({ timeDeleted: sql`now()` })
+          .where(inArray(KeyTable.userID, users.map((user) => user.id)))
+      }
+      await tx
+        .update(UserTable)
+        .set({ accountID: null, email: null, name: "", timeDeleted: sql`now()` })
+        .where(eq(UserTable.accountID, account.id))
+      if (emails.length > 0) {
+        await tx.delete(CouponTable).where(inArray(CouponTable.email, emails.map((row) => row.email)))
+      }
+      await tx.delete(AuthTable).where(eq(AuthTable.accountID, account.id))
+      await tx.update(AccountTable).set({ timeDeleted: sql`now()` }).where(eq(AccountTable.id, account.id))
+    })
+  })
+
   export const fromID = fn(z.string(), async (id) =>
     Database.use((tx) =>
       tx

+ 113 - 0
packages/console/core/src/referral.ts

@@ -362,6 +362,119 @@ export namespace Referral {
     })
   }
 
+  export async function create(input: { inviterWorkspaceID: string; inviteeWorkspaceID: string }) {
+    return Database.transaction(async (tx) => {
+      if (input.inviterWorkspaceID === input.inviteeWorkspaceID) throw new Error("Self-referral workspace mismatch")
+
+      const inviterWorkspace = await tx
+        .select({ id: WorkspaceTable.id })
+        .from(WorkspaceTable)
+        .where(and(eq(WorkspaceTable.id, input.inviterWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
+        .then((rows) => rows[0])
+      if (!inviterWorkspace) throw new Error(`Inviter workspace not found: ${input.inviterWorkspaceID}`)
+
+      const inviteeWorkspace = await tx
+        .select({ id: WorkspaceTable.id })
+        .from(WorkspaceTable)
+        .where(and(eq(WorkspaceTable.id, input.inviteeWorkspaceID), isNull(WorkspaceTable.timeDeleted)))
+        .then((rows) => rows[0])
+      if (!inviteeWorkspace) throw new Error(`Invitee workspace not found: ${input.inviteeWorkspaceID}`)
+
+      const invitee = await tx
+        .select({ accountID: UserTable.accountID, userID: UserTable.id, liteID: LiteTable.id })
+        .from(UserTable)
+        .innerJoin(
+          LiteTable,
+          and(
+            eq(LiteTable.workspaceID, UserTable.workspaceID),
+            eq(LiteTable.userID, UserTable.id),
+            isNull(LiteTable.timeDeleted),
+          ),
+        )
+        .where(
+          and(
+            eq(UserTable.workspaceID, input.inviteeWorkspaceID),
+            eq(UserTable.role, "admin"),
+            isNull(UserTable.timeDeleted),
+          ),
+        )
+        .then((rows) => rows[0])
+      if (!invitee?.accountID) throw new Error(`Invitee Lite workspace owner not found: ${input.inviteeWorkspaceID}`)
+
+      const inviterUser = await tx
+        .select({ id: UserTable.id })
+        .from(UserTable)
+        .where(
+          and(
+            eq(UserTable.workspaceID, input.inviterWorkspaceID),
+            eq(UserTable.accountID, invitee.accountID),
+            isNull(UserTable.timeDeleted),
+          ),
+        )
+        .then((rows) => rows[0])
+      if (inviterUser) throw new Error(`Self-referral is not allowed: ${invitee.accountID}`)
+
+      const existingReferral = await tx
+        .select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID })
+        .from(ReferralTable)
+        .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
+        .then((rows) => rows[0])
+      if (existingReferral && existingReferral.workspaceID !== input.inviterWorkspaceID) {
+        throw new Error(`Referral already belongs to ${existingReferral.workspaceID}: ${existingReferral.id}`)
+      }
+
+      const referralID = existingReferral?.id ?? Identifier.create("referral")
+      if (!existingReferral) {
+        await tx.insert(ReferralTable).ignore().values({
+          workspaceID: input.inviterWorkspaceID,
+          id: referralID,
+          inviteeAccountID: invitee.accountID,
+        })
+
+        const referral = await tx
+          .select({ id: ReferralTable.id })
+          .from(ReferralTable)
+          .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
+          .then((rows) => rows[0])
+        if (!referral) throw new Error(`Referral not created: ${invitee.accountID}`)
+        if (referral.id !== referralID) throw new Error(`Referral already redeemed: ${referral.id}`)
+      }
+
+      const rewardInsert = await tx
+        .insert(ReferralRewardTable)
+        .ignore()
+        .values([
+          { workspaceID: input.inviterWorkspaceID, referralID, amount: REWARD_AMOUNT },
+          { workspaceID: input.inviteeWorkspaceID, referralID, amount: REWARD_AMOUNT },
+        ])
+
+      const rewards = await tx
+        .select({ workspaceID: ReferralRewardTable.workspaceID, amount: ReferralRewardTable.amount })
+        .from(ReferralRewardTable)
+        .where(
+          and(
+            eq(ReferralRewardTable.referralID, referralID),
+            inArray(ReferralRewardTable.workspaceID, [input.inviterWorkspaceID, input.inviteeWorkspaceID]),
+            isNull(ReferralRewardTable.timeDeleted),
+          ),
+        )
+      if (rewards.length !== 2) throw new Error(`Referral rewards not created: ${referralID}`)
+      if (rewards.some((reward) => reward.amount !== REWARD_AMOUNT)) {
+        throw new Error(`Referral reward amount mismatch: ${referralID}`)
+      }
+
+      return {
+        referralID,
+        createdReferral: !existingReferral,
+        createdRewards: rewardInsert.rowsAffected,
+        inviteeAccountID: invitee.accountID,
+        inviteeUserID: invitee.userID,
+        liteID: invitee.liteID,
+        rewardWorkspaces: rewards.map((reward) => reward.workspaceID),
+      }
+    })
+  }
+
   export async function completeFromLiteSubscription(input: { workspaceID: string; userID: string }) {
     return Database.transaction(async (tx) => {
       const invitee = await tx

+ 8 - 0
packages/console/core/src/user.ts

@@ -11,6 +11,7 @@ import { Key } from "./key"
 import { KeyTable } from "./schema/key.sql"
 import { WorkspaceTable } from "./schema/workspace.sql"
 import { AuthTable } from "./schema/auth.sql"
+import { AccountTable } from "./schema/account.sql"
 
 export namespace User {
   const assertNotSelf = (id: string) => {
@@ -161,6 +162,13 @@ export namespace User {
   export const joinInvitedWorkspaces = fn(z.void(), async () => {
     const account = Actor.assert("account")
     const invitations = await Database.use(async (tx) => {
+      const active = await tx
+        .select({ id: AccountTable.id })
+        .from(AccountTable)
+        .where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
+        .then((rows) => rows[0])
+      if (!active) throw new Error("Account is not active")
+
       const invitations = await tx
         .select({
           id: UserTable.id,

+ 9 - 1
packages/console/core/src/workspace.ts

@@ -6,8 +6,9 @@ import { Identifier } from "./identifier"
 import { UserTable } from "./schema/user.sql"
 import { BillingTable } from "./schema/billing.sql"
 import { WorkspaceTable } from "./schema/workspace.sql"
+import { AccountTable } from "./schema/account.sql"
 import { Key } from "./key"
-import { eq, sql } from "drizzle-orm"
+import { and, eq, isNull, sql } from "drizzle-orm"
 
 export namespace Workspace {
   export const create = fn(
@@ -19,6 +20,13 @@ export namespace Workspace {
       const workspaceID = Identifier.create("workspace")
       const userID = Identifier.create("user")
       await Database.transaction(async (tx) => {
+        const active = await tx
+          .select({ id: AccountTable.id })
+          .from(AccountTable)
+          .where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
+          .then((rows) => rows[0])
+        if (!active) throw new Error("Account is not active")
+
         await tx.insert(WorkspaceTable).values({
           id: workspaceID,
           name,

+ 6 - 0
packages/enterprise/src/core/share.ts

@@ -147,6 +147,12 @@ export namespace Share {
     }
   })
 
+  export const removeAdmin = fn(Info.pick({ id: true }), async (body) => {
+    const share = await get(body.id)
+    if (!share) throw new Errors.NotFound(body.id)
+    await remove({ id: share.id, secret: share.secret })
+  })
+
   export const sync = fn(
     z.object({
       share: Info.pick({ id: true, secret: true }),

+ 18 - 0
packages/enterprise/src/routes/api/[...path].ts

@@ -5,6 +5,8 @@ import { validator } from "hono-openapi"
 import z from "zod"
 import { cors } from "hono/cors"
 import { Share } from "~/core/share"
+import { Resource } from "sst"
+import { timingSafeEqual } from "node:crypto"
 
 const app = new Hono()
 
@@ -137,6 +139,22 @@ app
       return c.json({})
     },
   )
+  .post(
+    "/support/actions/remove-share",
+    async (c) => {
+      const authorization = c.req.header("authorization")
+      const expected = `Bearer ${(Resource as unknown as Record<string, { value: string }>).SUPPORT_API_KEY.value}`
+      const actual = Buffer.from(authorization ?? "")
+      const secret = Buffer.from(expected)
+      if (actual.length !== secret.length || !timingSafeEqual(actual, secret)) return c.json({ error: "Unauthorized" }, 401)
+
+      const body = z.object({ shareID: z.string().min(1) }).safeParse(await c.req.json().catch(() => undefined))
+      if (!body.success) return c.json({ error: "Invalid request", issues: body.error.issues }, 400)
+      return Share.removeAdmin({ id: body.data.shareID })
+        .then(() => c.json({ success: true, message: "Share removed" }))
+        .catch((error) => c.json({ error: error instanceof Error ? error.message : String(error) }, 400))
+    },
+  )
 
 export function GET(event: APIEvent) {
   return app.fetch(event.request)

+ 8 - 0
packages/enterprise/test/core/share.test.ts

@@ -14,6 +14,14 @@ describe.concurrent("core.share", () => {
     await Share.remove({ id: share.id, secret: share.secret })
   })
 
+  test("should remove a share as admin", async () => {
+    const share = await Share.create({ sessionID: Identifier.descending() })
+
+    await Share.removeAdmin({ id: share.id })
+
+    expect(await Share.get(share.id)).toBeUndefined()
+  })
+
   test("should sync data to a share", async () => {
     const sessionID = Identifier.descending()
     const share = await Share.create({ sessionID })