diff --git a/TASKS.md b/TASKS.md index d932a94..3f85dd2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,22 +2,21 @@ ## Current -### `feat/demo-assets` +### `feat/current-workspace-session-model` Status: in_progress DoD: -- project has an idempotent PostgreSQL demo seed for live UI screens -- demo seed fills workspaces, memberships, invitations, operations, agents, keys and usage -- seed is controlled by env and does not live in migrations -- docs explain how to enable and use demo data +- current workspace is stored in authenticated session, not only in localStorage +- workspace switcher updates session state through auth API +- shell identity and page data follow session-selected workspace after reload +- frontend retains only cache/fallback state, not source-of-truth selection ## Next -- `feat/alpine-polish` +- `feat/agent-lifecycle-polish` ## Backlog -- `feat/current-workspace-session-model` - `feat/agent-lifecycle-polish` - `feat/platform-key-usage` diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 97d3952..64f69ff 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -15,7 +15,10 @@ use crate::{ create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent, save_agent_bindings, update_agent, }, - auth::{change_password, get_profile, get_session, login, logout, update_profile}, + auth::{ + change_password, get_profile, get_session, login, logout, update_current_workspace, + update_profile, + }, auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs}, operations::{ @@ -149,6 +152,7 @@ pub fn build_app(state: AppState) -> Router { .route("/logout", post(logout)) .route("/session", get(get_session)) .route("/profile", get(get_profile).patch(update_profile)) + .route("/current-workspace", post(update_current_workspace)) .route("/password", post(change_password)) .layer(middleware::from_fn_with_state( state.clone(), @@ -991,6 +995,57 @@ mod tests { assert_eq!(relogin_status, reqwest::StatusCode::OK); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn switches_current_workspace_in_session() { + let registry = test_registry().await; + let storage_root = test_storage_root("session_workspace"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let root_url = base_url + .as_ref() + .split("/api/admin/workspaces/") + .next() + .unwrap() + .to_owned(); + let client = authorized_client(&base_url).await; + + let created_workspace = assert_success_json( + client + .post(format!("{root_url}/api/admin/workspaces")) + .json(&json!({ + "slug": "growth-lab", + "display_name": "Growth Lab", + "settings": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let workspace_id = created_workspace["workspace"]["id"].as_str().unwrap(); + + let switched = assert_success_json( + client + .post(format!("{root_url}/api/auth/current-workspace")) + .json(&json!({ "workspace_id": workspace_id })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(switched["current_workspace_id"], workspace_id); + + let session = assert_success_json( + client + .get(format!("{root_url}/api/auth/session")) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(session["current_workspace_id"], workspace_id); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn exposes_logs_and_usage_from_real_test_runs() { diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs index 262ff21..a680a2f 100644 --- a/apps/admin-api/src/auth.rs +++ b/apps/admin-api/src/auth.rs @@ -38,8 +38,10 @@ pub struct AuthSettings { #[derive(Clone, Debug, Serialize)] pub struct AuthenticatedSession { + pub session_id: UserSessionId, pub user: User, pub memberships: Vec, + pub current_workspace_id: Option, } #[derive(Clone)] diff --git a/apps/admin-api/src/routes/auth.rs b/apps/admin-api/src/routes/auth.rs index 421d669..7cfb6a7 100644 --- a/apps/admin-api/src/routes/auth.rs +++ b/apps/admin-api/src/routes/auth.rs @@ -5,7 +5,9 @@ use serde_json::json; use crate::{ auth::{AuthenticatedSession, cleared_session_cookie, extract_session_token, session_cookie}, error::ApiError, - service::{ChangePasswordPayload, LoginPayload, UpdateProfilePayload}, + service::{ + ChangePasswordPayload, LoginPayload, UpdateCurrentWorkspacePayload, UpdateProfilePayload, + }, state::AppState, }; @@ -55,9 +57,11 @@ pub async fn get_session( pub async fn get_profile( Extension(session): Extension, ) -> Result, ApiError> { - Ok(Json( - json!({ "user": session.user, "memberships": session.memberships }), - )) + Ok(Json(json!({ + "user": session.user, + "memberships": session.memberships, + "current_workspace_id": session.current_workspace_id + }))) } pub async fn update_profile( @@ -67,7 +71,27 @@ pub async fn update_profile( ) -> Result, ApiError> { let updated = state .service - .update_profile(&session.user.id, payload) + .update_profile( + &session.user.id, + session.current_workspace_id.as_ref(), + payload, + ) + .await?; + Ok(Json(json!(updated))) +} + +pub async fn update_current_workspace( + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let updated = state + .service + .set_current_workspace( + &session.session_id, + &session.user.id, + &payload.workspace_id.as_str().into(), + ) .await?; Ok(Json(json!(updated))) } diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index d782b03..315d2f2 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -64,6 +64,7 @@ pub struct LoginPayload { pub struct SessionResponse { pub user: crank_core::User, pub memberships: Vec, + pub current_workspace_id: Option, } #[derive(Clone, Debug, Deserialize)] @@ -78,6 +79,11 @@ pub struct ChangePasswordPayload { pub new_password: String, } +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateCurrentWorkspacePayload { + pub workspace_id: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct OperationPayload { pub name: String, @@ -546,8 +552,10 @@ impl AdminService { .get_user_session(session_id, &secret_hash) .await? .map(|record| AuthenticatedSession { + session_id: record.session_id, user: record.user, memberships: record.memberships, + current_workspace_id: record.current_workspace_id, }); Ok(session) @@ -582,24 +590,31 @@ impl AdminService { &session_cookie.value, &self.auth_settings.session_secret, ); + let memberships = self + .registry + .list_workspaces_for_user(&user.user.id) + .await?; + let current_workspace_id = memberships + .first() + .map(|membership| membership.workspace.id.as_str().to_owned()); self.registry .create_user_session( &session_cookie.session_id, &user.user.id, + memberships + .first() + .map(|membership| &membership.workspace.id), &secret_hash, &session_cookie.expires_at, ) .await?; - let memberships = self - .registry - .list_workspaces_for_user(&user.user.id) - .await?; Ok(( session_cookie, SessionResponse { user: user.user, memberships, + current_workspace_id, }, )) } @@ -672,12 +687,16 @@ impl AdminService { .map(|session| SessionResponse { user: session.user, memberships: session.memberships, + current_workspace_id: session + .current_workspace_id + .map(|id| id.as_str().to_owned()), })) } pub async fn update_profile( &self, user_id: &crank_core::UserId, + current_workspace_id: Option<&WorkspaceId>, payload: UpdateProfilePayload, ) -> Result { let display_name = payload.display_name.trim(); @@ -696,7 +715,43 @@ impl AdminService { .await?; let memberships = self.registry.list_workspaces_for_user(user_id).await?; - Ok(SessionResponse { user, memberships }) + Ok(SessionResponse { + user, + memberships, + current_workspace_id: current_workspace_id.map(|id| id.as_str().to_owned()), + }) + } + + pub async fn set_current_workspace( + &self, + session_id: &UserSessionId, + user_id: &crank_core::UserId, + workspace_id: &WorkspaceId, + ) -> Result { + if !self + .user_has_workspace_access(user_id, workspace_id) + .await? + { + return Err(ApiError::forbidden("workspace access denied")); + } + + self.registry + .set_user_session_current_workspace(session_id, workspace_id) + .await?; + + let user = self + .registry + .get_auth_user_by_id(user_id) + .await? + .ok_or_else(|| ApiError::not_found(format!("user {} was not found", user_id.as_str())))? + .user; + let memberships = self.registry.list_workspaces_for_user(user_id).await?; + + Ok(SessionResponse { + user, + memberships, + current_workspace_id: Some(workspace_id.as_str().to_owned()), + }) } pub async fn change_password( diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 8c2bbfa..4aac3d9 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -162,6 +162,13 @@ body: JSON.stringify(payload), }); }, + setCurrentWorkspace: function(workspaceId) { + return request(AUTH_BASE + '/current-workspace', { + method: 'POST', + headers: headers({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ workspace_id: workspaceId }), + }); + }, listWorkspaces: function() { return get('/workspaces'); }, diff --git a/apps/ui/js/auth.js b/apps/ui/js/auth.js index bde39fd..5dd1500 100644 --- a/apps/ui/js/auth.js +++ b/apps/ui/js/auth.js @@ -24,9 +24,29 @@ } function primaryMembership(session) { - return session && session.memberships && session.memberships.length - ? session.memberships[0] - : null; + if (!(session && session.memberships && session.memberships.length)) { + return null; + } + if (session.current_workspace_id) { + var currentMembership = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === session.current_workspace_id; + }); + if (currentMembership) { + return currentMembership; + } + } + if (window.getCurrentWorkspace) { + var currentWorkspace = window.getCurrentWorkspace(); + if (currentWorkspace) { + var matched = session.memberships.find(function(item) { + return item.workspace && item.workspace.id === currentWorkspace.id; + }); + if (matched) { + return matched; + } + } + } + return session.memberships[0]; } function initials(displayName, email) { @@ -138,6 +158,7 @@ window.CrankAuth = { fetchSession: fetchSession, replaceSession: replaceSession, + getCachedSession: function() { return sessionCache; }, guardProtectedPage: guardProtectedPage, guardLoginPage: guardLoginPage, login: login, diff --git a/apps/ui/js/settings.js b/apps/ui/js/settings.js index c4262b4..1a46954 100644 --- a/apps/ui/js/settings.js +++ b/apps/ui/js/settings.js @@ -249,7 +249,7 @@ async function loadWorkspaceSettings() { var workspaces = await window.refreshWorkspaces(); var updated = workspaces.find(function (entry) { return entry.id === item.id; }); if (updated && window.setCurrentWorkspace) { - window.setCurrentWorkspace(updated); + await window.setCurrentWorkspace(updated); } } diff --git a/apps/ui/js/workspace-setup.js b/apps/ui/js/workspace-setup.js index 2762c5b..ba35b91 100644 --- a/apps/ui/js/workspace-setup.js +++ b/apps/ui/js/workspace-setup.js @@ -324,7 +324,7 @@ async function submitForm() { settings: payload.settings, }; - window.setCurrentWorkspace(mapped); + await window.setCurrentWorkspace(mapped); workspaceFormState.workspaceId = workspace.id; workspaceFormState.workspaceRecord = { workspace: workspace }; setFormDirty(false); diff --git a/apps/ui/js/workspace.js b/apps/ui/js/workspace.js index e2a3998..2f726ea 100644 --- a/apps/ui/js/workspace.js +++ b/apps/ui/js/workspace.js @@ -4,6 +4,7 @@ var WS_LIST = [ var workspaceLoadPromise = null; var workspaceLoadFailed = false; +var lastWorkspaceId = null; function workspaceColor(index) { return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5]; @@ -13,11 +14,14 @@ function mapWorkspace(record, index) { var workspace = record && record.workspace ? record.workspace : record; var displayName = workspace.display_name || workspace.slug || workspace.id; var settings = workspace.settings || {}; + var role = record && record.role + ? String(record.role).replace(/^\w/, function(char) { return char.toUpperCase(); }) + : 'Owner'; return { id: workspace.id, slug: workspace.slug, name: displayName, - role: 'Owner', + role: role, letter: displayName.charAt(0).toUpperCase(), color: settings.color || workspaceColor(index), status: workspace.status, @@ -25,29 +29,60 @@ function mapWorkspace(record, index) { }; } -function selectedWorkspaceId() { - return localStorage.getItem('crank_workspace_id'); +function cachedWorkspaceId() { + try { + return localStorage.getItem('crank_workspace_id'); + } catch (_error) { + return null; + } } -function selectedWorkspaceSlug() { - return localStorage.getItem('crank_workspace_slug'); +function cachedWorkspaceSlug() { + try { + return localStorage.getItem('crank_workspace_slug'); + } catch (_error) { + return null; + } } -function persistCurrentWorkspace(workspace) { +function sessionWorkspaceId() { + if (!window.CrankAuth || typeof window.CrankAuth.getCachedSession !== 'function') { + return null; + } + var session = window.CrankAuth.getCachedSession(); + return session && session.current_workspace_id ? session.current_workspace_id : null; +} + +function cacheCurrentWorkspace(workspace) { if (!workspace) return; - localStorage.setItem('crank_workspace_id', workspace.id); - localStorage.setItem('crank_workspace_slug', workspace.slug); + try { + localStorage.setItem('crank_workspace_id', workspace.id); + localStorage.setItem('crank_workspace_slug', workspace.slug); + } catch (_error) {} +} + +function resolveCurrentWorkspace() { + var workspaceId = sessionWorkspaceId() || cachedWorkspaceId(); + var workspaceSlug = cachedWorkspaceSlug(); + return WS_LIST.find(function(item) { return item.id === workspaceId; }) + || WS_LIST.find(function(item) { return item.slug === workspaceSlug; }) + || WS_LIST[0] + || null; +} + +function emitWorkspaceChange(workspace) { + if (!workspace) { + return; + } + lastWorkspaceId = workspace.id; + window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); } function getCurrentWs() { - var workspaceId = selectedWorkspaceId(); - var workspaceSlug = selectedWorkspaceSlug(); - var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; }) - || WS_LIST.find(function(item) { return item.slug === workspaceSlug; }) - || WS_LIST[0]; + var workspace = resolveCurrentWorkspace(); if (workspace) { - persistCurrentWorkspace(workspace); + cacheCurrentWorkspace(workspace); } return workspace; @@ -134,17 +169,39 @@ function toggleWsSwitcher(e) { function switchWorkspace(workspaceId) { var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; }); if (!workspace) return; - persistCurrentWorkspace(workspace); + var dd = document.getElementById('ws-dropdown'); if (dd) dd.style.display = 'none'; - renderWorkspaceList(); - window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); + + if (!window.CrankApi || !window.CrankAuth || typeof window.CrankAuth.replaceSession !== 'function') { + cacheCurrentWorkspace(workspace); + renderWorkspaceList(); + emitWorkspaceChange(workspace); + return; + } + + return window.CrankApi + .setCurrentWorkspace(workspace.id) + .then(function(session) { + window.CrankAuth.replaceSession(session); + cacheCurrentWorkspace(workspace); + renderWorkspaceList(); + emitWorkspaceChange(workspace); + return workspace; + }) + .catch(function(error) { + if (window.CrankUi) { + window.CrankUi.error(error.message || 'Failed to switch workspace', 'Workspace switch failed'); + } + throw error; + }); } function setCurrentWorkspace(workspace) { - persistCurrentWorkspace(workspace); - renderWorkspaceList(); - window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace })); + if (!workspace) { + return Promise.resolve(null); + } + return Promise.resolve(switchWorkspace(workspace.id)); } window.getCurrentWorkspace = getCurrentWs; @@ -155,6 +212,14 @@ window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; }; document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher); +window.addEventListener('crank:sessionchange', function() { + var workspace = getCurrentWs(); + renderWorkspaceList(); + if (workspace && workspace.id !== lastWorkspaceId) { + emitWorkspaceChange(workspace); + } +}); + document.addEventListener('click', function(e) { if (!e.target.closest('#ws-switcher')) { var dd = document.getElementById('ws-dropdown'); diff --git a/crates/crank-registry/src/migrations.rs b/crates/crank-registry/src/migrations.rs index f24dfb4..8560eb7 100644 --- a/crates/crank-registry/src/migrations.rs +++ b/crates/crank-registry/src/migrations.rs @@ -67,6 +67,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { "create table if not exists user_sessions ( id text primary key, user_id text not null references users(id) on delete cascade, + current_workspace_id text null references workspaces(id) on delete set null, secret_hash text not null, status text not null, expires_at timestamptz not null, @@ -77,6 +78,13 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + query( + "alter table user_sessions + add column if not exists current_workspace_id text null references workspaces(id) on delete set null", + ) + .execute(pool) + .await?; + query( "insert into workspaces ( id, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index d975e30..9b1b9c4 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -72,6 +72,7 @@ pub struct SessionRecord { pub session_id: UserSessionId, pub user: User, pub memberships: Vec, + pub current_workspace_id: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/crank-registry/src/postgres.rs b/crates/crank-registry/src/postgres.rs index 9fc38a3..66b8c06 100644 --- a/crates/crank-registry/src/postgres.rs +++ b/crates/crank-registry/src/postgres.rs @@ -257,6 +257,7 @@ impl PostgresRegistry { &self, session_id: &UserSessionId, user_id: &UserId, + current_workspace_id: Option<&WorkspaceId>, secret_hash: &str, expires_at: &str, ) -> Result<(), RegistryError> { @@ -264,6 +265,7 @@ impl PostgresRegistry { "insert into user_sessions ( id, user_id, + current_workspace_id, secret_hash, status, expires_at, @@ -273,14 +275,16 @@ impl PostgresRegistry { $1, $2, $3, + $4, 'active', - $4::timestamptz, + $5::timestamptz, now(), now() )", ) .bind(session_id.as_str()) .bind(user_id.as_str()) + .bind(current_workspace_id.map(|id| id.as_str())) .bind(secret_hash) .bind(expires_at) .execute(&self.pool) @@ -298,6 +302,7 @@ impl PostgresRegistry { "select s.id, s.user_id, + s.current_workspace_id, u.email, u.display_name, u.status, @@ -328,11 +333,26 @@ impl PostgresRegistry { created_at: row.try_get("created_at")?, }; let memberships = self.list_workspaces_for_user(&user_id).await?; + let stored_workspace_id = row + .try_get::, _>("current_workspace_id")? + .map(WorkspaceId::new); + let current_workspace_id = stored_workspace_id + .filter(|workspace_id| { + memberships + .iter() + .any(|membership| membership.workspace.id == *workspace_id) + }) + .or_else(|| { + memberships + .first() + .map(|membership| membership.workspace.id.clone()) + }); Ok(Some(SessionRecord { session_id: UserSessionId::new(row.try_get::("id")?), user, memberships, + current_workspace_id, })) } @@ -368,6 +388,24 @@ impl PostgresRegistry { Ok(()) } + pub async fn set_user_session_current_workspace( + &self, + session_id: &UserSessionId, + workspace_id: &WorkspaceId, + ) -> Result<(), RegistryError> { + sqlx::query( + "update user_sessions + set current_workspace_id = $2 + where id = $1", + ) + .bind(session_id.as_str()) + .bind(workspace_id.as_str()) + .execute(&self.pool) + .await?; + + Ok(()) + } + pub async fn user_has_workspace_access( &self, user_id: &UserId, diff --git a/docs/admin-api.md b/docs/admin-api.md index 09d59c8..f0077b9 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -76,15 +76,17 @@ - `GET /api/auth/session` - `GET /api/auth/profile` - `PATCH /api/auth/profile` +- `POST /api/auth/current-workspace` - `POST /api/auth/password` Контракт: - `POST /login` принимает `email` и `password`; - при успешном логине backend выставляет `HttpOnly` session cookie; -- `GET /session` возвращает текущего пользователя и memberships; -- `GET /profile` возвращает текущего пользователя и memberships для settings UI; +- `GET /session` возвращает текущего пользователя, memberships и `current_workspace_id`; +- `GET /profile` возвращает текущего пользователя, memberships и `current_workspace_id` для settings UI; - `PATCH /profile` обновляет `display_name` и `email` текущего пользователя; +- `POST /current-workspace` переключает текущий workspace внутри текущей authenticated session; - `POST /password` меняет пароль текущего пользователя после проверки `current_password`; - `POST /logout` инвалидирует текущую session. diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 61e775c..ab491cc 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -115,7 +115,6 @@ UI-файлы: Что еще не хватает: -- server-side current workspace model вместо client-side `localStorage`; - дальнейший UX polish вокруг gRPC discovery, потому что server reflection сознательно заменен на descriptor-driven live flow; - возможные smoke tests для `wizard`, чтобы закрепить уже подключенный live contract. @@ -128,6 +127,7 @@ UI-файлы: Простой итог: - wizard почти готов для реального backend; +- текущий workspace для wizard уже приходит из auth session, а не только из client-side `localStorage`; - это основной экран второй очереди после catalog; - базовый create/edit draft flow уже можно посадить на live `create/get version/update` endpoints; - следующий разрыв здесь - test/publish/import/export wiring и полноценный gRPC descriptor-set lifecycle. @@ -312,13 +312,10 @@ UI-файлы: Что еще не хватает: -- `switch current workspace` все еще живет на клиенте, а не в session/backend; -- session-aware current workspace model; - finer-grained permission matrix beyond current `owner/admin` management rules. Отдельный конфликт: -- current workspace по-прежнему client-side; - memberships, role-management и invitations уже live; - `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта @@ -326,7 +323,7 @@ UI-файлы: - `workspace-setup` уже подключен к live backend; - create/edit workspace, memberships, invitations, export и delete работают; -- текущий workspace все еще остается client-side моделью. +- текущий workspace уже синхронизируется через auth session и используется всеми live страницами. ### 4.8. Settings @@ -347,6 +344,7 @@ UI-файлы: - `GET /api/auth/session` - `GET /api/auth/profile` - `PATCH /api/auth/profile` +- `POST /api/auth/current-workspace` - `POST /api/auth/password` - workspace block через `GET /api/admin/workspaces/{workspace_id}` - `PATCH /api/admin/workspaces/{workspace_id}` @@ -354,8 +352,7 @@ UI-файлы: Что еще не хватает: - preferences endpoint; -- полноценный advanced security model (`2FA`, passkeys, session inventory); -- session-aware current workspace model вместо client-side `localStorage`. +- полноценный advanced security model (`2FA`, passkeys, session inventory). Отдельный конфликт: @@ -437,13 +434,13 @@ UI вводит scope `deploy`, backend пока не отражает полн ### 5.4. Workspace switching -UI хранит current workspace в `localStorage`. +UI хранит current workspace в auth session и использует `localStorage` только как cache/fallback. Решение: -- на ближайшем этапе оставить client-side current workspace; -- данные workspace брать с backend; -- позже заменить на session-aware current workspace model. +- backend хранит `current_workspace_id` в user session; +- `POST /api/auth/current-workspace` переключает активный workspace; +- клиент использует `localStorage` только как cache/fallback для shell state. ## 6. Порядок интеграции