diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 5568047ca..85704d72a 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -9,6 +9,7 @@ from .. import group from .....entity.errors import account as account_errors from ...context import RequestContext from .....cloud.launch import SpaceLaunchError +from .....workspace.errors import WorkspaceNotFoundError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError @@ -143,6 +144,13 @@ class UserRouterGroup(group.RouterGroup): try: redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') + cloud_entry = quart.request.args.get('cloud_entry') == '1' + if ( + cloud_entry + and not launch_workspace_uuid + and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud' + ): + return self.success(data={'authorize_url': self.ap.space_service.get_cloud_entry_url()}) if launch_workspace_uuid: if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): return self.fail(1, 'Space launch requires Cloud mode') @@ -429,13 +437,33 @@ class UserRouterGroup(group.RouterGroup): ) account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) + projection_service = self.ap.directory_projection_service + access = None + # A first Cloud launch creates the personal Workspace immediately + # before redirecting here. Pull a bounded number of signed event + # pages until both the Account and its target Workspace membership + # are visible instead of rejecting during the background-sync window. + for attempt in range(4): + if account is not None: + self.ap.user_service._require_active_account(account) + try: + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + account.uuid, + launch['workspace_uuid'], + ) + break + except WorkspaceNotFoundError: + if projection_service is None or attempt == 3: + raise + elif projection_service is None or attempt == 3: + break + + await projection_service.sync_once() + account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid']) if account is None: raise SpaceLaunchError('Launch Account is not projected into Core') - self.ap.user_service._require_active_account(account) - access = await self.ap.workspace_collaboration_service.resolve_account_workspace( - account.uuid, - launch['workspace_uuid'], - ) + if access is None: # pragma: no cover - bounded loop resolves or raises. + raise SpaceLaunchError('Launch Workspace is not projected into Core') token = await self.ap.user_service.generate_jwt_token(account) return self.success( data={ diff --git a/src/langbot/pkg/api/http/service/space.py b/src/langbot/pkg/api/http/service/space.py index 5be09a860..174cc965c 100644 --- a/src/langbot/pkg/api/http/service/space.py +++ b/src/langbot/pkg/api/http/service/space.py @@ -124,6 +124,11 @@ class SpaceService: params['state'] = state return f'{authorize_url}?{urlencode(params)}' + def get_cloud_entry_url(self) -> str: + """Return the Space-owned Cloud selector for a Cloud Account login.""" + + return f'{self._get_space_config()["url"].rstrip("/")}/cloud?environment=beta' + async def exchange_oauth_code( self, code: str, diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index be0a9f021..10a48a8c7 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -11,6 +11,7 @@ import pytest import quart from langbot.pkg.api.http.controller.groups.user import UserRouterGroup +from langbot.pkg.workspace.errors import WorkspaceNotFoundError pytestmark = pytest.mark.integration @@ -27,7 +28,8 @@ async def space_oauth_api(): execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1), ) application = Mock() - application.deployment = SimpleNamespace(multi_workspace_enabled=False) + application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss') + application.directory_projection_service = None application.persistence_mgr = None application.user_service.get_authenticated_account = AsyncMock(return_value=account) application.user_service.issue_space_oauth_state = AsyncMock( @@ -69,6 +71,7 @@ async def space_oauth_api(): application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' ) + application.space_service.get_cloud_entry_url = Mock(return_value='https://space.example/cloud?environment=beta') application.space_service.exchange_oauth_code = AsyncMock( return_value={ 'access_token': 'space-access-token', @@ -125,6 +128,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau ) +@pytest.mark.asyncio +async def test_cloud_login_entry_redirects_to_space_workspace_launcher(space_oauth_api): + application, client = space_oauth_api + application.deployment.mode = 'cloud' + + response = await client.get( + '/api/v1/user/space/authorize-url', + query_string={ + 'redirect_uri': 'http://localhost/auth/space/callback', + 'cloud_entry': '1', + }, + headers={'Origin': 'http://localhost'}, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['authorize_url'] == ('https://space.example/cloud?environment=beta') + application.space_service.get_cloud_entry_url.assert_called_once_with() + application.user_service.issue_space_oauth_state.assert_not_awaited() + + @pytest.mark.asyncio async def test_public_login_rejects_caller_supplied_state(space_oauth_api): application, client = space_oauth_api @@ -414,3 +437,52 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space ) application.user_service.consume_space_oauth_state.assert_not_awaited() application.space_service.exchange_oauth_code.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(space_oauth_api): + application, client = space_oauth_api + projected_account = SimpleNamespace( + uuid='account-a', + user='owner@example.com', + account_type='space', + status='active', + ) + application.user_service.get_user_by_uuid = AsyncMock(side_effect=[None, None, projected_account]) + application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + assert application.directory_projection_service.sync_once.await_count == 2 + assert application.user_service.get_user_by_uuid.await_count == 3 + + +@pytest.mark.asyncio +async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api): + application, client = space_oauth_api + projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value + application.workspace_collaboration_service.resolve_account_workspace = AsyncMock( + side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access] + ) + application.directory_projection_service = SimpleNamespace(sync_once=AsyncMock()) + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID + application.directory_projection_service.sync_once.assert_awaited_once_with() + assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2 diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 32f6c9b66..41aac21cb 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -1385,12 +1385,18 @@ export class BackendClient extends BaseHttpClient { } // ============ Space OAuth API (Redirect Flow) ============ - public getSpaceAuthorizeUrl(redirectUri: string): Promise<{ + public getSpaceAuthorizeUrl( + redirectUri: string, + options?: { cloudEntry?: boolean }, + ): Promise<{ authorize_url: string; }> { return this.get( '/api/v1/user/space/authorize-url', - { redirect_uri: redirectUri }, + { + redirect_uri: redirectUri, + ...(options?.cloudEntry ? { cloud_entry: '1' } : {}), + }, { skipWorkspace: true }, ); } diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 48436017f..061a21988 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -202,7 +202,13 @@ export default function Login() { try { const currentOrigin = window.location.origin; const redirectUri = `${currentOrigin}/auth/space/callback`; - const response = await httpClient.getSpaceAuthorizeUrl(redirectUri); + const response = await httpClient.getSpaceAuthorizeUrl(redirectUri, { + // Cloud Accounts must be launched from Space so a first visit can + // lazily create and project the personal Workspace. Invitation login + // remains on the OAuth callback path because it targets the invited + // Workspace instead. + cloudEntry: !getPendingInvitationToken(), + }); window.location.href = response.authorize_url; } catch { toast.error(t('common.spaceLoginFailed')); diff --git a/web/tests/unit/cloud-new-account-entry.test.mjs b/web/tests/unit/cloud-new-account-entry.test.mjs new file mode 100644 index 000000000..5420da59e --- /dev/null +++ b/web/tests/unit/cloud-new-account-entry.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync( + new URL('../../src/app/login/page.tsx', import.meta.url), + 'utf8', +); + +test('normal Cloud login enters through the Space Workspace launcher', () => { + assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); + assert.match(source, /getSpaceAuthorizeUrl\(redirectUri,\s*\{/); +}); + +test('invitation login remains on the OAuth callback path', () => { + assert.match(source, /cloudEntry:\s*!getPendingInvitationToken\(\)/); +});