use axum::Json; use axum::extract::FromRequestParts; use axum::http::StatusCode; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; use chrono::{DateTime, Utc}; use lazyboy_contracts::{ Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind, computer_home_key, computer_scope_key, }; use serde_json::json; use sqlx::{FromRow, PgPool}; use uuid::Uuid; #[derive(Clone)] pub struct Db { pub pool: PgPool, } /// Who a request is acting as: one person and the workspace they own. Every /// query for an agent, chat, run, or desktop is filtered by both, so two /// people registered on the same server can never see — or collide with — /// each other's work. #[derive(Debug, Clone)] pub struct Actor { pub user_id: String, pub space_id: String, } /// Handlers ask for `actor: Actor` and axum fills it from the session the auth /// middleware resolved, so no handler can accidentally run as somebody else. impl FromRequestParts for Actor { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { parts.extensions.get::().cloned().ok_or_else(|| { ( StatusCode::UNAUTHORIZED, Json(json!({"message": "請先登入", "code": "unauthenticated"})), ) .into_response() }) } } /// A registered person. `password_hash` is `None` for a legacy account that /// predates logins and still has to be claimed. #[derive(Debug, Clone)] pub struct Account { pub user_id: String, pub username: String, pub password_hash: Option, } #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] pub struct ComputerRow { pub id: String, pub space_id: String, pub user_id: String, pub scope: String, pub scope_key: String, pub home_key: String, pub home_revision: String, pub kind: String, pub provider_ref: Option, pub state: String, pub control_holder: String, pub control_lease_id: Option, pub control_lease_expires_at: Option>, pub control_bot_id: Option, pub control_run_id: Option, pub execution_run_id: Option, pub execution_bot_id: Option, pub execution_lease_expires_at: Option>, pub execution_fence: i32, pub browser_profile_mode: String, } #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] pub struct ScreenRow { pub id: String, pub computer_id: String, pub bot_id: String, pub slot: i32, pub display: String, pub view_port: i32, pub profile_mode: String, pub profile_path: String, pub control_holder: String, pub control_lease_id: Option, pub control_lease_expires_at: Option>, pub execution_run_id: Option, pub execution_lease_expires_at: Option>, pub execution_fence: i32, } #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] pub struct BotRow { pub id: String, pub space_id: String, pub user_id: String, pub name: String, pub title: String, pub description: String, pub avatar_color: String, pub avatar_shape: String, pub tags: Vec, pub pinned: bool, pub hidden: bool, pub group_name: Option, pub unread_count: i64, pub last_message_at: Option>, pub instructions: String, pub computer_id: Option, pub model_provider: Option, pub model_id: Option, pub memory_enabled: bool, } #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] pub struct SpaceRow { pub id: String, pub user_id: String, pub name: String, pub default_model_provider: String, pub default_model_id: String, pub default_model_base_url: Option, pub default_model_api_key: Option, pub voice_enabled: bool, pub voice_provider: Option, pub voice_model_id: Option, pub voice_id: Option, pub voice_api_key: Option, } #[derive(Debug, Clone, FromRow)] #[allow(dead_code)] pub struct SpaceModelProviderRow { pub space_id: String, pub provider: String, pub model_id: String, pub base_url: Option, pub api_key: Option, } impl Db { /// Register a person. /// /// The first account on an upgraded install claims the workspace that /// already exists (it belonged to the shared-token install), so nobody /// loses their agents, chats, or desktops; from the second account on, each /// person starts in a workspace of their own. pub async fn create_account( &self, username: &str, password_hash: &str, ) -> Result { let mut tx = self.pool.begin().await?; let claimable: Option<(String,)> = sqlx::query_as( "SELECT id FROM users WHERE password_hash IS NULL AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL) ORDER BY created_at, id LIMIT 1 FOR UPDATE", ) .fetch_optional(&mut *tx) .await?; let user_id = match claimable { Some((user_id,)) => { // Guarded so two simultaneous first registrations cannot both // take the same legacy row: the loser finds no row to change. let updated = sqlx::query( "UPDATE users SET username = $2, password_hash = $3 WHERE id = $1 AND password_hash IS NULL AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)", ) .bind(&user_id) .bind(username) .bind(password_hash) .execute(&mut *tx) .await?; if updated.rows_affected() != 1 { return Err(sqlx::Error::RowNotFound); } user_id } None => { let user_id = Uuid::new_v4().simple().to_string(); sqlx::query( "INSERT INTO users (id, name, username, password_hash) VALUES ($1, $2, $2, $3)", ) .bind(&user_id) .bind(username) .bind(password_hash) .execute(&mut *tx) .await?; user_id } }; tx.commit().await?; Ok(user_id) } pub async fn account_by_username( &self, username: &str, ) -> Result, sqlx::Error> { let found: Option<(String, String, Option)> = sqlx::query_as( "SELECT id, username, password_hash FROM users WHERE lower(username) = $1", ) .bind(username) .fetch_optional(&self.pool) .await?; Ok(found.map(|(user_id, username, password_hash)| Account { user_id, username, password_hash, })) } /// The workspace a person owns. An account without one is treated as /// signed out rather than handed somebody else's. pub async fn default_space(&self, user_id: &str) -> Result, sqlx::Error> { let space: Option<(String,)> = sqlx::query_as( "SELECT id FROM spaces WHERE user_id = $1 ORDER BY is_default DESC, created_at LIMIT 1", ) .bind(user_id) .fetch_optional(&self.pool) .await?; Ok(space.map(|(id,)| id)) } /// Create the workspace on first use, then return it. Provider and model /// defaults are empty of credentials on purpose: a key belongs in the /// workspace settings, and without one nothing runs. pub async fn ensure_default_space(&self, user_id: &str) -> Result { if let Some(space) = self.default_space(user_id).await? { return Ok(space); } let space_id = format!("space-{user_id}"); sqlx::query( "INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id) VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6') ON CONFLICT (id) DO NOTHING", ) .bind(&space_id) .bind(user_id) .bind("My workspace") .execute(&self.pool) .await?; Ok(self.default_space(user_id).await?.unwrap_or(space_id)) } /// Every account that can sign in, for background work that must run per /// person instead of once for the whole server. pub async fn actors(&self) -> Result, sqlx::Error> { let rows: Vec<(String, Option)> = sqlx::query_as( "SELECT u.id, (SELECT s.id FROM spaces s WHERE s.user_id = u.id ORDER BY s.is_default DESC, s.created_at LIMIT 1) AS space_id FROM users u WHERE u.password_hash IS NOT NULL", ) .fetch_all(&self.pool) .await?; let mut actors = Vec::new(); for (user_id, space_id) in rows { // An account with no workspace cannot act on anything, so it is // skipped rather than given a guessed space. if let Some(space_id) = space_id { actors.push(Actor { user_id, space_id }); } } Ok(actors) } pub async fn create_session( &self, user_id: &str, token_hash: &str, ttl_seconds: i64, ) -> Result<(), sqlx::Error> { let mut tx = self.pool.begin().await?; sqlx::query("DELETE FROM app_sessions WHERE expires_at <= now()") .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO app_sessions (token_hash, user_id, expires_at) VALUES ($1, $2, now() + make_interval(secs => $3::double precision))", ) .bind(token_hash) .bind(user_id) .bind(ttl_seconds as f64) .execute(&mut *tx) .await?; tx.commit().await } pub async fn session_actor(&self, token_hash: &str) -> Result, sqlx::Error> { let row: Option<(String, Option)> = sqlx::query_as( "SELECT s.user_id, (SELECT x.id FROM spaces x WHERE x.user_id = s.user_id ORDER BY x.is_default DESC, x.created_at LIMIT 1) AS space_id FROM app_sessions s WHERE s.token_hash = $1 AND s.expires_at > now()", ) .bind(token_hash) .fetch_optional(&self.pool) .await?; let Some((user_id, Some(space_id))) = row else { return Ok(None); }; Ok(Some(Actor { user_id, space_id })) } pub async fn session_username(&self, token_hash: &str) -> Result, sqlx::Error> { let row: Option<(Option,)> = sqlx::query_as( "SELECT u.username FROM users u JOIN app_sessions s ON s.user_id = u.id WHERE s.token_hash = $1 AND s.expires_at > now()", ) .bind(token_hash) .fetch_optional(&self.pool) .await?; Ok(row.and_then(|(username,)| username)) } pub async fn delete_session(&self, token_hash: &str) -> Result<(), sqlx::Error> { sqlx::query("DELETE FROM app_sessions WHERE token_hash = $1") .bind(token_hash) .execute(&self.pool) .await?; Ok(()) } pub async fn get_space(&self, actor: &Actor) -> Result, sqlx::Error> { sqlx::query_as( "SELECT id, user_id, name, default_model_provider, default_model_id, default_model_base_url, default_model_api_key, voice_enabled, voice_provider, voice_model_id, voice_id, voice_api_key FROM spaces WHERE id = $1 AND user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(&self.pool) .await } pub async fn space_model_provider( &self, actor: &Actor, provider: &str, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key FROM space_model_providers p JOIN spaces s ON s.id = p.space_id WHERE p.space_id = $1 AND s.user_id = $2 AND p.provider = $3", ) .bind(&actor.space_id) .bind(&actor.user_id) .bind(provider) .fetch_optional(&self.pool) .await } pub async fn list_space_model_providers( &self, actor: &Actor, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT p.space_id, p.provider, p.model_id, p.base_url, p.api_key FROM space_model_providers p JOIN spaces s ON s.id = p.space_id WHERE p.space_id = $1 AND s.user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(&self.pool) .await } pub async fn update_workspace_model( &self, actor: &Actor, provider: &str, model_id: &str, base_url: Option<&str>, api_key: Option>, ) -> Result { let mut tx = self.pool.begin().await?; match api_key { Some(key) => { sqlx::query( "UPDATE spaces SET default_model_provider = $3, default_model_id = $4, default_model_base_url = $5, default_model_api_key = $6 WHERE id = $1 AND user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .bind(provider) .bind(model_id) .bind(base_url) .bind(key) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO space_model_providers (space_id, provider, model_id, base_url, api_key) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (space_id, provider) DO UPDATE SET model_id = EXCLUDED.model_id, base_url = EXCLUDED.base_url, api_key = EXCLUDED.api_key, updated_at = now()", ) .bind(&actor.space_id) .bind(provider) .bind(model_id) .bind(base_url) .bind(key) .execute(&mut *tx) .await?; } None => { sqlx::query( "UPDATE spaces SET default_model_provider = $3, default_model_id = $4, default_model_base_url = $5 WHERE id = $1 AND user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .bind(provider) .bind(model_id) .bind(base_url) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO space_model_providers (space_id, provider, model_id, base_url) VALUES ($1, $2, $3, $4) ON CONFLICT (space_id, provider) DO UPDATE SET model_id = EXCLUDED.model_id, base_url = EXCLUDED.base_url, updated_at = now()", ) .bind(&actor.space_id) .bind(provider) .bind(model_id) .bind(base_url) .execute(&mut *tx) .await?; } } tx.commit().await?; self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound) } pub async fn update_voice_settings( &self, actor: &Actor, enabled: Option, provider: &str, model_id: &str, voice_id: &str, api_key: Option>, ) -> Result { match api_key { Some(key) => { sqlx::query( "UPDATE spaces SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_api_key = $6, voice_enabled = COALESCE($7, voice_enabled) WHERE id = $1 AND user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .bind(provider) .bind(model_id) .bind(voice_id) .bind(key) .bind(enabled) .execute(&self.pool) .await?; } None => { sqlx::query( "UPDATE spaces SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_enabled = COALESCE($6, voice_enabled) WHERE id = $1 AND user_id = $2", ) .bind(&actor.space_id) .bind(&actor.user_id) .bind(provider) .bind(model_id) .bind(voice_id) .bind(enabled) .execute(&self.pool) .await?; } } self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound) } pub async fn list_bots( &self, actor: &Actor, ) -> Result, sqlx::Error> { let bots: Vec = sqlx::query_as( "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, b.pinned, b.hidden, b.group_name, (SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count, (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at, b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled FROM bots b WHERE b.space_id = $1 AND b.user_id = $2 ORDER BY b.pinned DESC, b.created_at DESC", ) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(&self.pool) .await?; let mut out = Vec::new(); for bot in bots { let thread_id: (String,) = sqlx::query_as( "SELECT id FROM threads WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 AND room_id IS NULL ORDER BY updated_at DESC, created_at ASC LIMIT 1", ) .bind(&bot.id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_one(&self.pool) .await?; let computer = self .get_computer(bot.computer_id.as_deref().unwrap_or("")) .await?; if let Some(computer) = computer { out.push((bot, thread_id.0, computer)); } } Ok(out) } pub async fn get_bot( &self, actor: &Actor, bot_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags, b.pinned, b.hidden, b.group_name, (SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count, (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at, b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled FROM bots b WHERE b.id = $1 AND b.space_id = $2 AND b.user_id = $3", ) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(&self.pool) .await } pub async fn get_computer( &self, computer_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, browser_profile_mode FROM computers WHERE id = $1", ) .bind(computer_id) .fetch_optional(&self.pool) .await } pub async fn create_bot( &self, actor: &Actor, name: &str, title: &str, description: &str, instructions: &str, mode: ComputerMode, model_provider: Option<&str>, model_id: Option<&str>, memory_enabled: bool, ) -> Result { let mut tx = self.pool.begin().await?; let bot_id = Uuid::new_v4().to_string(); let thread_id = Uuid::new_v4().to_string(); let computer = ensure_computer( &mut tx, actor, mode, (mode == ComputerMode::Dedicated).then_some(bot_id.as_str()), ) .await?; sqlx::query( "INSERT INTO bots (id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id, memory_enabled) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(&bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .bind(name) .bind(title) .bind(description) .bind(instructions) .bind(&computer.id) .bind(model_provider) .bind(model_id) .bind(memory_enabled) .execute(&mut *tx) .await?; sqlx::query("INSERT INTO threads (id, space_id, bot_id, user_id, title) VALUES ($1,$2,$3,$4,'新對話')") .bind(&thread_id) .bind(&actor.space_id) .bind(&bot_id) .bind(&actor.user_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Bot { id: bot_id, space_id: actor.space_id.clone(), name: name.into(), title: title.into(), description: description.into(), avatar_color: "#8B5CF6".into(), avatar_shape: "blob".into(), tags: Vec::new(), pinned: false, hidden: false, group_name: None, unread_count: 0, last_message_at: None, instructions: instructions.into(), thread_id, computer_id: computer.id, computer_mode: mode, model_provider: model_provider.and_then(|value| value.parse().ok()), model_id: model_id.map(str::to_string), memory_enabled, }) } pub async fn get_screen( &self, computer_id: &str, bot_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path, control_holder, control_lease_id, control_lease_expires_at, execution_run_id, execution_lease_expires_at, execution_fence FROM computer_screens WHERE computer_id = $1 AND bot_id = $2", ) .bind(computer_id) .bind(bot_id) .fetch_optional(&self.pool) .await } pub async fn list_screen_slots(&self, computer_id: &str) -> Result, sqlx::Error> { sqlx::query_scalar("SELECT slot FROM computer_screens WHERE computer_id = $1 ORDER BY slot") .bind(computer_id) .fetch_all(&self.pool) .await } pub async fn list_screens(&self, computer_id: &str) -> Result, sqlx::Error> { sqlx::query_as( "SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path, control_holder, control_lease_id, control_lease_expires_at, execution_run_id, execution_lease_expires_at, execution_fence FROM computer_screens WHERE computer_id = $1 ORDER BY slot", ) .bind(computer_id) .fetch_all(&self.pool) .await } pub async fn active_run( &self, bot_id: &str, ) -> Result, sqlx::Error> { sqlx::query_as( "SELECT id, status, thread_id FROM runs WHERE bot_id = $1 AND status IN ('queued','leased','running','waiting_input','waiting_takeover') ORDER BY created_at DESC LIMIT 1", ) .bind(bot_id) .fetch_optional(&self.pool) .await } } async fn ensure_computer( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, actor: &Actor, mode: ComputerMode, bot_id: Option<&str>, ) -> Result { let scope_key = computer_scope_key(mode, &actor.space_id, bot_id).expect("scope key"); let home_key = computer_home_key(mode, &actor.space_id, bot_id).expect("home key"); sqlx::query( "INSERT INTO computers (id, space_id, user_id, scope, scope_key, home_key, kind, state) VALUES ($1,$2,$3,$4,$5,$6,'docker','stopped') ON CONFLICT (scope_key) DO NOTHING", ) .bind(Uuid::new_v4().to_string()) .bind(&actor.space_id) .bind(&actor.user_id) .bind(mode.as_str()) .bind(&scope_key) .bind(&home_key) .execute(&mut **tx) .await?; sqlx::query_as( "SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state, control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id, execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence, browser_profile_mode FROM computers WHERE scope_key = $1", ) .bind(scope_key) .fetch_one(&mut **tx) .await } pub fn parse_mode(scope: &str) -> ComputerMode { scope.parse().unwrap_or(ComputerMode::Team) } pub fn parse_state(state: &str) -> ComputerState { match state { "booting" => ComputerState::Booting, "running" => ComputerState::Running, "suspended" => ComputerState::Suspended, "error" => ComputerState::Error, _ => ComputerState::Stopped, } } pub fn parse_holder(holder: &str) -> ControlHolder { match holder { "bot" => ControlHolder::Bot, "user" => ControlHolder::User, _ => ControlHolder::None, } } pub fn parse_kind(kind: &str) -> SandboxKind { let _ = kind; SandboxKind::Docker } pub fn parse_profile_mode(value: &str) -> BrowserProfileMode { value.parse().unwrap_or(BrowserProfileMode::PerBot) } pub fn parse_run_status(status: &str) -> Option { match status { "queued" => Some(RunStatus::Queued), "leased" => Some(RunStatus::Leased), "running" => Some(RunStatus::Running), "waiting_input" => Some(RunStatus::WaitingInput), "waiting_takeover" => Some(RunStatus::WaitingTakeover), "completed" => Some(RunStatus::Completed), "failed" => Some(RunStatus::Failed), "cancelled" => Some(RunStatus::Cancelled), _ => None, } } #[cfg(test)] mod tests { use super::{Actor, Db}; /// One install, many people: an account may only reach the workspace it owns. /// This replaced a shared token that opened everything, so scoping is the /// whole security model here and it is worth a database test. #[sqlx::test(migrations = "../../migrations")] async fn each_account_only_reaches_its_own_workspace(pool: sqlx::PgPool) { // An install from before accounts existed: one row that cannot sign in, // owning every agent. sqlx::query("INSERT INTO users (id, name) VALUES ('local-user', 'Local')") .execute(&pool) .await .unwrap(); let db = Db { pool: pool.clone() }; let alice = db.create_account("alice", "hash-a").await.unwrap(); assert_eq!(alice, "local-user", "the first account adopts the old data"); let alice_space = db.ensure_default_space(&alice).await.unwrap(); let bob = db.create_account("bob", "hash-b").await.unwrap(); let bob_space = db.ensure_default_space(&bob).await.unwrap(); assert_ne!(alice_space, bob_space, "nobody shares a workspace"); // A username is an address: one spelling, so `ALICE` is not a second door. assert!( db.create_account("ALICE", "hash-c").await.is_err(), "a differently cased name must not open the same account" ); sqlx::query("INSERT INTO bots (id, space_id, user_id, name) VALUES ('bot-a', $1, $2, 'A')") .bind(&alice_space) .bind(&alice) .execute(&pool) .await .unwrap(); let alice_actor = Actor { user_id: alice.clone(), space_id: alice_space, }; let bob_actor = Actor { user_id: bob.clone(), space_id: bob_space, }; assert!(db.get_bot(&alice_actor, "bot-a").await.unwrap().is_some()); assert!( db.get_bot(&bob_actor, "bot-a").await.unwrap().is_none(), "another account's agent is not even visible by id" ); // Background work iterates people, so each actor must carry only their // own space. let mut actors = db.actors().await.unwrap(); let scoped: Vec<(String, String)> = actors .drain(..) .map(|actor| (actor.user_id, actor.space_id)) .collect(); assert_eq!( scoped.len(), 2, "the account that still cannot sign in is left out" ); assert!( scoped.contains(&(alice, alice_actor.space_id)), "each account is handed exactly its own workspace" ); assert!(scoped.contains(&(bob, bob_actor.space_id))); } #[sqlx::test(migrations = "../../migrations")] async fn switching_provider_keeps_the_previous_key(pool: sqlx::PgPool) { let db = Db { pool }; let user = db.create_account("sam", "hash").await.unwrap(); let space_id = db.ensure_default_space(&user).await.unwrap(); let actor = Actor { user_id: user, space_id, }; db.update_workspace_model(&actor, "xai", "grok-4.6", None, Some(Some("xai-key"))) .await .unwrap(); db.update_workspace_model(&actor, "opencode-go", "glm-5.1", None, Some(Some("go-key"))) .await .unwrap(); let xai = db .space_model_provider(&actor, "xai") .await .unwrap() .expect("xAI row stays after switching away"); assert_eq!(xai.api_key.as_deref(), Some("xai-key")); assert_eq!(xai.model_id, "grok-4.6"); let go = db .space_model_provider(&actor, "opencode-go") .await .unwrap() .unwrap(); assert_eq!(go.api_key.as_deref(), Some("go-key")); let stored = db .space_model_provider(&actor, "xai") .await .unwrap() .unwrap(); db.update_workspace_model( &actor, "xai", "grok-4.5", None, Some(stored.api_key.as_deref()), ) .await .unwrap(); let space = db.get_space(&actor).await.unwrap().unwrap(); assert_eq!(space.default_model_provider, "xai"); assert_eq!(space.default_model_id, "grok-4.5"); assert_eq!(space.default_model_api_key.as_deref(), Some("xai-key")); let go = db .space_model_provider(&actor, "opencode-go") .await .unwrap() .unwrap(); assert_eq!(go.api_key.as_deref(), Some("go-key")); } }