From c5aada494d8798dff119357410d8e128ade17a06 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 2 Aug 2026 00:33:46 +0800 Subject: [PATCH] fix(cloud): treat workspace owners as Space-bound (#2378) Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com> --- .../pkg/api/http/controller/groups/user.py | 13 ++++++++-- .../integration/api/test_user_space_oauth.py | 25 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 8b86ba361..560f5988c 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -285,8 +285,17 @@ class UserRouterGroup(group.RouterGroup): request_context.workspace_uuid, ) owner = await self.ap.user_service.get_workspace_owner(access.workspace.uuid) - owner_space_bound = bool(owner and owner.space_account_uuid) - credits = await self.ap.space_service.get_credits(owner.user) if owner_space_bound else None + cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + owner_has_local_space_credentials = bool(owner and owner.space_account_uuid) + # Cloud Accounts authenticate through LangBot Account, so every projected + # Workspace owner is already bound even when this Core has no local OAuth + # token row (model billing uses the owner's control-plane API key). + owner_space_bound = cloud_mode or owner_has_local_space_credentials + credits = ( + await self.ap.space_service.get_credits(owner.user) + if owner is not None and owner.space_account_uuid + else None + ) return self.success( data={ 'credits': credits, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index deb1e93e2..35e048053 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -272,9 +272,10 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): '/api/v1/user/space-credits', headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, ) + payload = await response.get_json() assert response.status_code == 200 - assert (await response.get_json())['data'] == { + assert payload['data'] == { 'credits': 25000, 'owner_space_bound': True, 'is_workspace_owner': True, @@ -282,6 +283,28 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api): application.space_service.get_credits.assert_awaited_once_with('owner@example.com') +@pytest.mark.asyncio +async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + application.user_service.get_workspace_owner = AsyncMock(return_value=None) + application.space_service.get_credits = AsyncMock() + + response = await client.get( + '/api/v1/user/space-credits', + headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID}, + ) + payload = await response.get_json() + + assert response.status_code == 200 + assert payload['data'] == { + 'credits': None, + 'owner_space_bound': True, + 'is_workspace_owner': True, + } + application.space_service.get_credits.assert_not_awaited() + + @pytest.mark.asyncio async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api): application, client = space_oauth_api