feat(console): keep migrated teams on the new Console (#46830)

This commit is contained in:
Victor Navarro 2026-09-02 17:52:15 +02:00 committed by GitHub
parent 69c172e8a7
commit 50efc055de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 112 additions and 18 deletions

View file

@ -221,6 +221,15 @@ const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
// Preview branches have independent databases; do not send their workspaces to shared dev.
const migrationDomain =
$app.stage === "production" ? "opencode.ai" : $app.stage === "dev" ? "dev.opencode.ai" : undefined
const consoleMigration = new sst.Linkable("ConsoleMigration", {
properties: {
consoleUrl: migrationDomain ? `https://${migrationDomain}/console` : "",
inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "",
},
})
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
@ -255,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
consoleMigration,
STRIPE_WEBHOOK_SECRET,
SECRET.SupportApiKey,
DISCORD_INCIDENT_WEBHOOK_URL,

View file

@ -1,6 +1,7 @@
import { getRequestEvent } from "solid-js/web"
import { and, Database, eq, inArray, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
@ -79,8 +80,15 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
if (accounts.length) {
const user = await Database.use((tx) =>
tx
.select()
.select({
id: UserTable.id,
workspaceID: UserTable.workspaceID,
accountID: UserTable.accountID,
role: UserTable.role,
migratedAt: WorkspaceTable.migrated_at,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(
and(
eq(UserTable.workspaceID, workspace),
@ -93,6 +101,15 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
.then((x) => x[0]),
)
if (user) {
if (user.migratedAt) {
const destination = Resource.ConsoleMigration.consoleUrl
if (!destination) throw new Error("New Console URL is not configured")
evt.response.headers.set("Cache-Control", "no-store")
throw redirect(`${destination}/login`, {
status: evt.request.method === "GET" || evt.request.method === "HEAD" ? 302 : 303,
headers: { "Cache-Control": "no-store" },
})
}
await Database.use((tx) =>
tx
.update(UserTable)

View file

@ -0,0 +1,55 @@
import { Resource } from "@opencode-ai/console-resource"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
const paths: Record<string, string | undefined> = {
"GET /zen/v1/models": "/openai/v1/models",
"POST /zen/v1/chat/completions": "/openai/v1/chat/completions",
"POST /zen/v1/responses": "/openai/v1/responses",
"POST /zen/v1/messages": "/anthropic/v1/messages",
}
export async function proxyInference(request: Request, clientIP?: string): Promise<Response | undefined> {
const url = new URL(request.url)
const path =
paths[`${request.method} ${url.pathname}`] ??
(request.method === "POST" &&
/^\/zen\/v1\/models\/[^/]+:(?:generateContent|streamGenerateContent)$/.test(url.pathname)
? url.pathname.replace("/zen/v1/models/", "/google/v1beta/models/")
: undefined)
if (!path) return undefined
const key = path.startsWith("/anthropic/")
? request.headers.get("x-api-key")
: path.startsWith("/google/")
? request.headers.get("x-goog-api-key")
: request.headers.get("authorization")?.split(" ")[1]
if (!key || key === "public") return undefined
// Routing only; the destination owns authentication and revocation after cutover.
const workspace = await Database.use((tx) =>
tx
.select({ migratedAt: WorkspaceTable.migrated_at })
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.where(eq(KeyTable.key, key))
.limit(1)
.then((rows) => rows[0]),
)
if (!workspace?.migratedAt) return undefined
const destination = new URL(Resource.ConsoleMigration.inferenceUrl)
destination.pathname = `${destination.pathname.replace(/\/$/, "")}${path}`
destination.search = url.search
destination.hash = ""
const forwarded = new Request(destination, request)
forwarded.headers.set("authorization", `Bearer ${key}`)
const ip = request.headers.get("cf-connecting-ip") ?? clientIP
if (ip) forwarded.headers.set("x-real-ip", ip)
const requestID = request.headers.get("x-opencode-request-id") ?? request.headers.get("x-opencode-request")
if (requestID) forwarded.headers.set("x-opencode-request-id", requestID)
return fetch(forwarded, { redirect: "error" })
}

View file

@ -2,9 +2,10 @@ import { createMiddleware } from "@solidjs/start/middleware"
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
import { normalizeReferralCode, referralCookie } from "~/lib/referral-invite"
import { sanitizeServerActionRequest } from "~/lib/server-action"
import { proxyInference } from "~/lib/inference-proxy"
export default createMiddleware({
onRequest(event) {
async onRequest(event) {
event.request = sanitizeServerActionRequest(event.request)
const url = new URL(event.request.url)
@ -19,5 +20,12 @@ export default createMiddleware({
const referralCode = normalizeReferralCode(url.searchParams.get("ref"))
if (referralCode) event.response.headers.append("set-cookie", referralCookie(referralCode))
return proxyInference(event.request, event.clientAddress).catch(() =>
Response.json(
{ error: { type: "api_error", message: "Inference routing is unavailable. Please retry later." } },
{ status: 503, headers: { "Cache-Control": "no-store" } },
),
)
},
})

View file

@ -38,21 +38,25 @@ const setReload = action(async (form: FormData) => {
}
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
...(reloadAmount !== null ? { reloadAmount } : {}),
...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
timeReloadError: null,
}
: {}),
})
.where(eq(BillingTable.workspaceID, workspaceID)),
await withActor(
() =>
Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
...(reloadAmount !== null ? { reloadAmount } : {}),
...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
timeReloadError: null,
}
: {}),
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)

View file

@ -88,7 +88,7 @@ const updateModel = action(async (form: FormData) => {
if (!workspaceID) return { error: formError.workspaceRequired }
const enabled = (form.get("enabled") as string | null) === "true"
return json(
withActor(async () => {
await withActor(async () => {
if (enabled) {
await Model.disable({ model })
} else {