core: make account login upgrades safe while adding multi-account workspace auth (#15487)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dax
2026-03-10 12:53:47 -04:00
committed by GitHub
parent 9c4325bcf8
commit 613562f504
41 changed files with 4793 additions and 918 deletions

View File

@@ -1,4 +1,5 @@
import { Bus } from "@/bus"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
@@ -11,8 +12,51 @@ import type * as SDK from "@opencode-ai/sdk/v2"
export namespace ShareNext {
const log = Log.create({ service: "share-next" })
type ApiEndpoints = {
create: string
sync: (shareId: string) => string
remove: (shareId: string) => string
data: (shareId: string) => string
}
function apiEndpoints(resource: string): ApiEndpoints {
return {
create: `/api/${resource}`,
sync: (shareId) => `/api/${resource}/${shareId}/sync`,
remove: (shareId) => `/api/${resource}/${shareId}`,
data: (shareId) => `/api/${resource}/${shareId}/data`,
}
}
const legacyApi = apiEndpoints("share")
const consoleApi = apiEndpoints("shares")
export async function url() {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
const req = await request()
return req.baseUrl
}
export async function request(): Promise<{
headers: Record<string, string>
api: ApiEndpoints
baseUrl: string
}> {
const headers: Record<string, string> = {}
const active = Account.active()
if (!active?.active_org_id) {
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
return { headers, api: legacyApi, baseUrl }
}
const token = await Account.token(active.id)
if (!token) {
throw new Error("No active account token available for sharing")
}
headers["authorization"] = `Bearer ${token}`
headers["x-org-id"] = active.active_org_id
return { headers, api: consoleApi, baseUrl: active.url }
}
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -68,15 +112,20 @@ export namespace ShareNext {
export async function create(sessionID: string) {
if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID })
const result = await fetch(`${await url()}/api/share`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: sessionID }),
})
.then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string })
if (!response.ok) {
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`)
}
const result = (await response.json()) as { id: string; url: string; secret: string }
Database.use((db) =>
db
.insert(SessionShareTable)
@@ -159,16 +208,19 @@ export namespace ShareNext {
const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({
secret: share.secret,
data: Array.from(queued.data.values()),
}),
})
if (!response.ok) {
log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
}
}, 1000)
queue.set(sessionID, { timeout, data: dataMap })
}
@@ -178,15 +230,21 @@ export namespace ShareNext {
log.info("removing share", { sessionID })
const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
const req = await request()
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
headers: { ...req.headers, "Content-Type": "application/json" },
body: JSON.stringify({
secret: share.secret,
}),
})
if (!response.ok) {
const message = await response.text().catch(() => response.statusText)
throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`)
}
Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
}

View File

@@ -1,6 +1,6 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { SessionTable } from "../session/session.sql"
import { Timestamps } from "@/storage/schema.sql"
import { Timestamps } from "../storage/schema.sql"
export const SessionShareTable = sqliteTable("session_share", {
session_id: text()