core: support managing multiple authenticated accounts with individual workspace access

Enable users to authenticate with multiple accounts and switch between
them, accessing workspaces from each account separately.
opencode/proud-rocket
Dax Raad 2026-02-28 14:23:55 -05:00
parent b5515dd2f7
commit 7b5b665b4a
5 changed files with 1089 additions and 37 deletions

View File

@ -0,0 +1,18 @@
ALTER TABLE `account` ADD `id` text;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_account` (
`id` text PRIMARY KEY,
`email` text NOT NULL,
`url` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`token_expiry` integer,
`active` integer NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_account`(`email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated`) SELECT `email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated` FROM `account`;--> statement-breakpoint
DROP TABLE `account`;--> statement-breakpoint
ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,15 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "@/storage/schema.sql"
export const AccountTable = sqliteTable(
"account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [
primaryKey({ columns: [table.email, table.url] }),
// uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
],
)
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
})

View File

@ -1,10 +1,11 @@
import { eq, and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Database } from "@/storage/db"
import { AccountTable } from "./account.sql"
import z from "zod"
export namespace Account {
export const Account = z.object({
id: z.string(),
email: z.string(),
url: z.string(),
})
@ -12,18 +13,40 @@ export namespace Account {
function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account {
return {
id: row.id,
email: row.email,
url: row.url,
}
}
export function account(): Account | undefined {
export function active(): Account | undefined {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get())
return row ? fromRow(row) : undefined
}
export async function token(): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get())
export function list(): Account[] {
return Database.use((db) => db.select().from(AccountTable).all().map(fromRow))
}
export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return []
const access = await token(accountID)
if (!access) return []
const res = await fetch(`${row.url}/api/orgs`, {
headers: { authorization: `Bearer ${access}` },
})
if (!res.ok) return []
const json = (await res.json()) as Array<{ id?: string; name?: string }>
return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" }))
}
export async function token(accountID: string): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
@ -52,7 +75,7 @@ export namespace Account {
refresh_token: json.refresh_token ?? row.refresh_token,
token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
})
.where(and(eq(AccountTable.email, row.email), eq(AccountTable.url, row.url)))
.where(eq(AccountTable.id, row.id))
.run(),
)
@ -122,23 +145,20 @@ export namespace Account {
access_token?: string
refresh_token?: string
expires_in?: number
email?: string
error?: string
error_description?: string
}
if (json.access_token) {
let email = json.email
if (!email) {
const me = await fetch(`${input.server}/api/user`, {
headers: { authorization: `Bearer ${json.access_token}` },
})
const user = (await me.json()) as { email?: string }
if (!user.email) {
return { type: "error", msg: "No email in response" }
}
email = user.email
const me = await fetch(`${input.server}/api/user`, {
headers: { authorization: `Bearer ${json.access_token}` },
})
const user = (await me.json()) as { id?: string; email?: string }
if (!user.id || !user.email) {
return { type: "error", msg: "No id or email in response" }
}
const id = user.id
const email = user.email
const access = json.access_token
const expiry = Date.now() + json.expires_in! * 1000
@ -148,6 +168,7 @@ export namespace Account {
db.update(AccountTable).set({ active: false }).run()
db.insert(AccountTable)
.values({
id,
email,
url: input.url,
access_token: access,
@ -156,7 +177,7 @@ export namespace Account {
active: true,
})
.onConflictDoUpdate({
target: [AccountTable.email, AccountTable.url],
target: AccountTable.id,
set: {
access_token: access,
refresh_token: refresh,

View File

@ -107,7 +107,8 @@ export namespace Config {
}
}
const token = await Account.token()
const active = Account.active()
const token = active ? await Account.token(active.id) : undefined
if (token) {
}