From 8a445bfb227e556aba38f4f907394514f63b58fd Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:40:36 +0800 Subject: [PATCH] feat(workspace): add in-product collaboration and direct Cloud launch --- .../pkg/api/http/controller/groups/system.py | 6 + .../pkg/api/http/controller/groups/user.py | 78 ++++- .../api/http/controller/groups/workspaces.py | 33 +- src/langbot/pkg/api/http/service/user.py | 39 ++- src/langbot/pkg/cloud/launch.py | 238 +++++++++++++++ src/langbot/pkg/core/app.py | 6 + src/langbot/pkg/core/stages/build_app.py | 4 + .../versions/0015_cloud_core_collaboration.py | 75 +++++ src/langbot/pkg/persistence/mgr.py | 5 +- src/langbot/pkg/workspace/collaboration.py | 19 +- .../pkg/workspace/invitation_delivery.py | 288 ++++++++++++++++++ src/langbot/templates/config.yaml | 24 ++ .../integration/api/test_user_space_oauth.py | 82 ++++- tests/integration/api/test_workspaces.py | 44 ++- .../persistence/test_migrations_postgres.py | 2 +- .../api/service/test_user_service.py | 21 +- tests/unit_tests/cloud/test_space_launch.py | 120 ++++++++ .../workspace/test_invitation_delivery.py | 88 ++++++ .../workspace/test_workspace_collaboration.py | 37 ++- web/src/app/auth/space/callback/page.tsx | 53 +++- .../WorkspaceSettingsPanel.tsx | 37 +-- web/src/app/infra/entities/api/index.ts | 4 + web/src/app/infra/entities/workspace.ts | 7 + web/src/app/infra/http/BackendClient.ts | 18 +- web/src/app/infra/http/index.ts | 4 + web/src/i18n/locales/en-US.ts | 12 +- web/src/i18n/locales/ja-JP.ts | 11 +- web/src/i18n/locales/zh-Hans.ts | 12 +- .../unit/workspace-switcher-layout.test.mjs | 15 +- 29 files changed, 1258 insertions(+), 124 deletions(-) create mode 100644 src/langbot/pkg/cloud/launch.py create mode 100644 src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py create mode 100644 src/langbot/pkg/workspace/invitation_delivery.py create mode 100644 tests/unit_tests/cloud/test_space_launch.py create mode 100644 tests/unit_tests/workspace/test_invitation_delivery.py diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index 9f1161891..330799335 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -9,6 +9,7 @@ from .....entity.persistence.metadata import WorkspaceMetadata from ...authz import Permission from ...context import RequestContext from .....provider.tools.loaders.mcp_policy import stdio_mcp_enabled +from .....workspace.invitation_delivery import InvitationDeliveryService @group.group_class('system', '/api/v1/system') @@ -75,6 +76,10 @@ class SystemRouterGroup(group.RouterGroup): else: outbound_ips = [] + invitation_delivery_service = getattr(self.ap, 'invitation_delivery_service', None) + if invitation_delivery_service is None: + invitation_delivery_service = InvitationDeliveryService(self.ap) + return self.success( data={ 'version': constants.semantic_version, @@ -97,6 +102,7 @@ class SystemRouterGroup(group.RouterGroup): 'mcp_stdio_enabled': stdio_mcp_enabled(self.ap), 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), 'outbound_ips': outbound_ips, + 'invitation_delivery': invitation_delivery_service.capability(), 'wizard_status': wizard_status, 'wizard_progress': wizard_progress, } diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index d9a4239df..d6f2f4b9c 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -1,11 +1,13 @@ import quart import argon2 import asyncio +import uuid from urllib.parse import parse_qs, urlsplit from .. import group from .....entity.errors import account as account_errors from ...context import RequestContext +from .....cloud.launch import SpaceLaunchError from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError @@ -153,7 +155,20 @@ class UserRouterGroup(group.RouterGroup): try: redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False) - state = await self.ap.user_service.issue_space_oauth_state('login') + launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid') + 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') + try: + uuid.UUID(launch_workspace_uuid) + except ValueError: + return self.fail(1, 'Invalid launch Workspace') + state = await self.ap.user_service.issue_space_oauth_state( + 'login', + launch_workspace_uuid=launch_workspace_uuid, + ) + else: + state = await self.ap.user_service.issue_space_oauth_state('login') authorize_url = self.ap.space_service.get_oauth_authorize_url(redirect_uri, state) return self.success(data={'authorize_url': authorize_url}) except ValueError as e: @@ -184,6 +199,14 @@ class UserRouterGroup(group.RouterGroup): json_data = await quart.request.json code = json_data.get('code') state = json_data.get('state') + launch_assertion = json_data.get('launch_assertion') + workspace_uuid = json_data.get('workspace_uuid') + + if launch_assertion: + return await self._handle_space_direct_launch( + str(launch_assertion), + str(workspace_uuid or '') or None, + ) if not code: return self.fail(1, 'Missing authorization code') @@ -191,7 +214,7 @@ class UserRouterGroup(group.RouterGroup): return self.fail(1, 'Missing state parameter') try: - await self.ap.user_service.consume_space_oauth_state(state, 'login') + consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login') # Exchange code for tokens token_data = await self.ap.space_service.exchange_oauth_code(code) access_token = token_data.get('access_token') @@ -206,6 +229,24 @@ class UserRouterGroup(group.RouterGroup): access_token, refresh_token, expires_in ) + launch_workspace_uuid = consumed_state.launch_workspace_uuid + if launch_workspace_uuid: + try: + access = await self.ap.workspace_collaboration_service.resolve_account_workspace( + user_obj.uuid, + launch_workspace_uuid, + ) + except Exception: + self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace') + return self.fail(1, 'Space OAuth failed') + return self.success( + data={ + 'token': jwt_token, + 'user': user_obj.user, + 'workspace_uuid': access.workspace.uuid, + } + ) + return self.success( data={ 'token': jwt_token, @@ -331,3 +372,36 @@ class UserRouterGroup(group.RouterGroup): return self.http_status(400, -1, 'Space account binding failed') except Exception: raise + + async def _handle_space_direct_launch( + self, + launch_assertion: str, + workspace_uuid: str | None, + ) -> str: + try: + launch = await self.ap.space_launch_service.consume_assertion( + launch_assertion, + expected_workspace_uuid=workspace_uuid, + ) + 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'], + ) + token = await self.ap.user_service.generate_jwt_token(account) + return self.success( + data={ + 'token': token, + 'user': account.user, + 'workspace_uuid': access.workspace.uuid, + } + ) + except SpaceLaunchError: + self.ap.logger.warning('Rejected Space direct-launch assertion') + return self.fail(1, 'Space launch failed') + except Exception: + self.ap.logger.exception('Space direct launch failed') + return self.fail(1, 'Space launch failed') diff --git a/src/langbot/pkg/api/http/controller/groups/workspaces.py b/src/langbot/pkg/api/http/controller/groups/workspaces.py index 72964c881..07f28f257 100644 --- a/src/langbot/pkg/api/http/controller/groups/workspaces.py +++ b/src/langbot/pkg/api/http/controller/groups/workspaces.py @@ -11,6 +11,7 @@ from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, Wo from .....entity.persistence.workspace import WorkspaceSource from .....workspace.collaboration import WorkspaceMemberView from .....workspace.errors import WorkspaceNotFoundError +from .....workspace.invitation_delivery import InvitationDeliveryService from .. import group @@ -153,8 +154,6 @@ class WorkspacesRouterGroup(group.RouterGroup): ) async def _(workspace_uuid: str, request_context: RequestContext) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) - if await self._requires_control_plane(workspace_uuid): - return self._control_plane_required() if quart.request.method == 'GET': invitations = await self.ap.workspace_collaboration_service.list_invitations( workspace_uuid, @@ -169,10 +168,20 @@ class WorkspacesRouterGroup(group.RouterGroup): str(data.get('email', '')), str(data.get('role', 'viewer')), ) + delivery_service = self._invitation_delivery_service() + link = delivery_service.build_invitation_link(created.token) + workspace = await self.ap.workspace_service.get_workspace(workspace_uuid) + delivery = await delivery_service.deliver_invitation( + recipient_email=created.invitation.normalized_email, + workspace_name=workspace.name, + invitation_link=link, + ) return self.success( data={ 'invitation': _invitation_payload(created.invitation), 'token': created.token, + 'link': link, + 'delivery': delivery.to_public_dict(), } ) @@ -187,8 +196,6 @@ class WorkspacesRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) - if await self._requires_control_plane(workspace_uuid): - return self._control_plane_required() invitation = await self.ap.workspace_collaboration_service.revoke_invitation( workspace_uuid, invitation_uuid, @@ -207,8 +214,6 @@ class WorkspacesRouterGroup(group.RouterGroup): request_context: RequestContext, ) -> typing.Any: self._require_current_workspace(workspace_uuid, request_context) - if await self._requires_control_plane(workspace_uuid): - return self._control_plane_required() if quart.request.method == 'DELETE': if Permission.MEMBER_REMOVE.value not in request_context.workspace.permissions: return self.http_status(403, 'permission_denied', 'Member removal permission is required') @@ -241,16 +246,12 @@ class WorkspacesRouterGroup(group.RouterGroup): if workspace_uuid != request_context.workspace_uuid: raise WorkspaceNotFoundError('Workspace not found') - async def _requires_control_plane(self, workspace_uuid: str) -> bool: - workspace = await self.ap.workspace_service.get_workspace(workspace_uuid) - return workspace.source == WorkspaceSource.CLOUD_PROJECTION.value - - def _control_plane_required(self) -> typing.Any: - return self.http_status( - 409, - 'control_plane_required', - 'Cloud Workspace membership and invitations are managed by the SaaS control plane', - ) + def _invitation_delivery_service(self) -> InvitationDeliveryService: + service = getattr(self.ap, 'invitation_delivery_service', None) + if service is None: + service = InvitationDeliveryService(self.ap) + self.ap.invitation_delivery_service = service + return service @staticmethod def _member_view_payload(view: WorkspaceMemberView) -> dict[str, typing.Any]: diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index e5f1d5d1f..5d602e7b9 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -6,6 +6,7 @@ import jwt import datetime import typing import asyncio +import dataclasses import hashlib import secrets import time @@ -39,6 +40,13 @@ class AccountDisabledError(ValueError): code = 'account_disabled' +@dataclasses.dataclass(frozen=True, slots=True) +class SpaceOAuthStateConsumption: + purpose: typing.Literal['login', 'bind'] + account: user.User | None + launch_workspace_uuid: str | None = None + + class UserService: ap: Application _create_user_lock: asyncio.Lock @@ -48,7 +56,7 @@ class UserService: self._create_user_lock = asyncio.Lock() self._password_hash_lock = asyncio.Semaphore(1) self._space_oauth_state_lock = asyncio.Lock() - self._space_oauth_states: dict[str, tuple[str, str | None, float]] = {} + self._space_oauth_states: dict[str, tuple[str, str | None, float, str | None]] = {} @staticmethod def _space_oauth_state_digest(state: str) -> str: @@ -59,6 +67,7 @@ class UserService: purpose: typing.Literal['login', 'bind'], *, account_uuid: str | None = None, + launch_workspace_uuid: str | None = None, ttl_seconds: int = 600, ) -> str: """Issue an opaque, single-use OAuth state without exposing a JWT.""" @@ -66,6 +75,8 @@ class UserService: raise ValueError('An Account is required for Space binding') if purpose == 'login' and account_uuid is not None: raise ValueError('Login state cannot be bound to an Account') + if purpose != 'login' and launch_workspace_uuid is not None: + raise ValueError('Launch Workspace state is only valid for Space login') if ttl_seconds <= 0: raise ValueError('OAuth state lifetime must be positive') @@ -78,15 +89,15 @@ class UserService: if len(self._space_oauth_states) >= 4096: oldest = min(self._space_oauth_states, key=lambda key: self._space_oauth_states[key][2]) self._space_oauth_states.pop(oldest, None) - self._space_oauth_states[digest] = (purpose, account_uuid, expires_at) + self._space_oauth_states[digest] = (purpose, account_uuid, expires_at, launch_workspace_uuid) return raw_state - async def consume_space_oauth_state( + async def consume_space_oauth_state_details( self, raw_state: str, purpose: typing.Literal['login', 'bind'], - ) -> user.User | None: - """Atomically consume OAuth state and resolve its active bind Account.""" + ) -> SpaceOAuthStateConsumption: + """Atomically consume OAuth state and return any bound launch intent.""" if not isinstance(raw_state, str) or not raw_state: raise ValueError('Invalid or expired OAuth state') digest = self._space_oauth_state_digest(raw_state) @@ -95,14 +106,27 @@ class UserService: if entry is None or entry[0] != purpose or entry[2] <= time.monotonic(): raise ValueError('Invalid or expired OAuth state') if purpose == 'login': - return None + return SpaceOAuthStateConsumption( + purpose='login', + account=None, + launch_workspace_uuid=entry[3], + ) account_uuid = entry[1] account = await self.get_user_by_uuid(account_uuid or '') if account is None: raise ValueError('Invalid or expired OAuth state') self._require_active_account(account) - return account + return SpaceOAuthStateConsumption(purpose='bind', account=account) + + async def consume_space_oauth_state( + self, + raw_state: str, + purpose: typing.Literal['login', 'bind'], + ) -> user.User | None: + """Atomically consume OAuth state and resolve its active bind Account.""" + consumed = await self.consume_space_oauth_state_details(raw_state, purpose) + return consumed.account async def _hash_password(self, password: str) -> str: async with self._password_hash_lock: @@ -209,7 +233,6 @@ class UserService: ) -> tuple[user.User, typing.Any, str]: """Create an invited Account and accept its Membership in one transaction.""" - self._require_local_directory() normalized_email = normalize_email(user_email) invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token) if invitation.normalized_email != normalized_email: diff --git a/src/langbot/pkg/cloud/launch.py b/src/langbot/pkg/cloud/launch.py new file mode 100644 index 000000000..ab8baa7e4 --- /dev/null +++ b/src/langbot/pkg/cloud/launch.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import asyncio +import base64 +import binascii +import hashlib +import json +import os +import time +import typing +from collections.abc import Callable, Iterable + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +if typing.TYPE_CHECKING: + from ..core.app import Application + + +CONTROL_PLANE_TYP = 'langbot-control-plane+jwt' +LAUNCH_KIND = 'workspace.launch' +EXPECTED_ISSUER = 'langbot-space' +EXPECTED_AUDIENCE = 'langbot-cloud-runtime' + + +class SpaceLaunchError(ValueError): + """Raised when a Space-issued Cloud launch assertion is not admissible.""" + + +def _decode_base64url(value: str, *, label: str) -> bytes: + if not value or any(character.isspace() for character in value): + raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url') + try: + raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True) + except (binascii.Error, ValueError) as exc: + raise SpaceLaunchError(f'Launch assertion {label} is not valid base64url') from exc + if base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') != value: + raise SpaceLaunchError(f'Launch assertion {label} is not canonical base64url') + return raw + + +def _strict_json_object(value: bytes, *, label: str) -> dict[str, typing.Any]: + def reject_duplicate_keys(pairs: Iterable[tuple[str, typing.Any]]) -> dict[str, typing.Any]: + result: dict[str, typing.Any] = {} + for key, item in pairs: + if key in result: + raise SpaceLaunchError(f'Launch assertion {label} contains duplicate key {key!r}') + result[key] = item + return result + + try: + decoded = json.loads(value, object_pairs_hook=reject_duplicate_keys) + except SpaceLaunchError: + raise + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SpaceLaunchError(f'Launch assertion {label} is not valid JSON') from exc + if not isinstance(decoded, dict): + raise SpaceLaunchError(f'Launch assertion {label} must be a JSON object') + return decoded + + +def _required_string(claims: dict[str, typing.Any], name: str) -> str: + value = claims.get(name) + if not isinstance(value, str) or not value or value != value.strip(): + raise SpaceLaunchError(f'Launch assertion claim {name} must be a non-empty string') + return value + + +def _required_int(claims: dict[str, typing.Any], name: str, *, minimum: int = 0) -> int: + value = claims.get(name) + if isinstance(value, bool) or not isinstance(value, int) or value < minimum: + raise SpaceLaunchError(f'Launch assertion claim {name} must be an integer >= {minimum}') + return value + + +def _load_ed25519_public_key(encoded: str) -> Ed25519PublicKey: + value = encoded.strip() + if value.startswith('-----BEGIN'): + try: + key = serialization.load_pem_public_key(value.encode('ascii')) + except (ValueError, TypeError) as exc: + raise SpaceLaunchError('Space launch public key is not valid PEM') from exc + if not isinstance(key, Ed25519PublicKey): + raise SpaceLaunchError('Space launch public key must be Ed25519') + return key + + try: + raw = base64.b64decode(value + ('=' * (-len(value) % 4)), altchars=b'-_', validate=True) + except (binascii.Error, ValueError) as exc: + raise SpaceLaunchError('Space launch public key must be base64 encoded') from exc + if len(raw) != 32: + raise SpaceLaunchError('Space launch Ed25519 public key must contain 32 bytes') + return Ed25519PublicKey.from_public_bytes(raw) + + +class SpaceLaunchService: + """Verify and single-use consume Space Cloud direct-launch assertions.""" + + def __init__( + self, + ap: Application, + *, + wall_time: Callable[[], float] = time.time, + ) -> None: + self.ap = ap + self._wall_time = wall_time + self._replay_lock = asyncio.Lock() + self._consumed_jtis: dict[str, int] = {} + + async def consume_assertion( + self, + assertion: str, + *, + expected_workspace_uuid: str | None = None, + ) -> dict[str, str]: + claims = self._verify_assertion(assertion) + payload = claims.get('payload') + if not isinstance(payload, dict): + raise SpaceLaunchError('Launch assertion payload must be a JSON object') + account_uuid = _required_string(payload, 'account_uuid') + workspace_uuid = _required_string(payload, 'workspace_uuid') + if expected_workspace_uuid is not None and workspace_uuid != expected_workspace_uuid: + raise SpaceLaunchError('Launch assertion targets another Workspace') + await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1)) + return { + 'account_uuid': account_uuid, + 'workspace_uuid': workspace_uuid, + } + + def _verify_assertion(self, token: str) -> dict[str, typing.Any]: + if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False): + raise SpaceLaunchError('Space direct launch requires verified Cloud mode') + public_key, key_id, clock_skew_seconds = self._trust_config() + segments = token.split('.') + if len(segments) != 3: + raise SpaceLaunchError('Launch assertion must be a compact JWS') + encoded_header, encoded_claims, encoded_signature = segments + header = _strict_json_object(_decode_base64url(encoded_header, label='header'), label='header') + if set(header) != {'alg', 'kid', 'typ'}: + raise SpaceLaunchError('Launch assertion header contains unsupported fields') + if header.get('alg') != 'EdDSA': + raise SpaceLaunchError('Launch assertion algorithm must be EdDSA') + if header.get('kid') != key_id: + raise SpaceLaunchError('Launch assertion key ID does not match Cloud trust') + if header.get('typ') != CONTROL_PLANE_TYP: + raise SpaceLaunchError('Launch assertion type is not a control-plane payload') + + signature = _decode_base64url(encoded_signature, label='signature') + if len(signature) != 64: + raise SpaceLaunchError('Launch assertion signature must contain 64 bytes') + try: + public_key.verify(signature, f'{encoded_header}.{encoded_claims}'.encode('ascii')) + except InvalidSignature as exc: + raise SpaceLaunchError('Launch assertion signature is invalid') from exc + + claims = _strict_json_object(_decode_base64url(encoded_claims, label='claims'), label='claims') + instance_uuid = self.ap.workspace_service.instance_uuid + if _required_string(claims, 'iss') != EXPECTED_ISSUER: + raise SpaceLaunchError('Launch assertion issuer is not LangBot Space') + if _required_string(claims, 'aud') != EXPECTED_AUDIENCE: + raise SpaceLaunchError('Launch assertion audience does not target Cloud runtime') + if _required_string(claims, 'sub') != f'langbot-instance:{instance_uuid}': + raise SpaceLaunchError('Launch assertion subject targets another instance') + if _required_string(claims, 'instance_uuid') != instance_uuid: + raise SpaceLaunchError('Launch assertion instance UUID does not match this Core') + if _required_string(claims, 'kind') != LAUNCH_KIND: + raise SpaceLaunchError('Launch assertion kind is not workspace.launch') + + issued_at = _required_int(claims, 'iat') + not_before = _required_int(claims, 'nbf') + expires_at = _required_int(claims, 'exp', minimum=1) + now = self._wall_time() + if issued_at > now + clock_skew_seconds: + raise SpaceLaunchError('Launch assertion was issued in the future') + if not_before > now + clock_skew_seconds: + raise SpaceLaunchError('Launch assertion is not active yet') + if expires_at <= now - clock_skew_seconds: + raise SpaceLaunchError('Launch assertion is expired') + if expires_at <= max(issued_at, not_before): + raise SpaceLaunchError('Launch assertion expiry must follow issue time') + return claims + + def _trust_config(self) -> tuple[Ed25519PublicKey, str, float]: + data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} + space_config = data.get('space', {}) + launch_config = space_config.get('launch', {}) if isinstance(space_config, dict) else {} + if not isinstance(launch_config, dict): + launch_config = {} + public_key_value = ( + os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY', '').strip() + or str(launch_config.get('control_plane_public_key', '') or '').strip() + ) + key_id = ( + os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_KEY_ID', '').strip() + or str(launch_config.get('control_plane_key_id', '') or '').strip() + or str(getattr(getattr(self.ap, 'deployment', None), 'verification_key_id', '') or '').strip() + ) + if not public_key_value or not key_id: + raise SpaceLaunchError('Space launch control-plane trust is not configured') + clock_skew = self._bounded_float( + os.environ.get('LANGBOT_SPACE_CONTROL_PLANE_CLOCK_SKEW_SECONDS') + or launch_config.get('clock_skew_seconds'), + default=30.0, + minimum=0.0, + maximum=300.0, + ) + return _load_ed25519_public_key(public_key_value), key_id, clock_skew + + async def _consume_jti(self, jti: str, expires_at: int) -> None: + digest = hashlib.sha256(jti.encode('utf-8')).hexdigest() + now = int(self._wall_time()) + async with self._replay_lock: + self._consumed_jtis = { + existing: expiry for existing, expiry in self._consumed_jtis.items() if expiry > now + } + if digest in self._consumed_jtis: + raise SpaceLaunchError('Launch assertion has already been consumed') + if len(self._consumed_jtis) >= 4096: + oldest = min(self._consumed_jtis, key=lambda key: self._consumed_jtis[key]) + self._consumed_jtis.pop(oldest, None) + self._consumed_jtis[digest] = expires_at + + @staticmethod + def _bounded_float( + value: typing.Any, + *, + default: float, + minimum: float, + maximum: float, + ) -> float: + try: + result = float(value) + except (TypeError, ValueError): + return default + if not minimum <= result <= maximum: + return default + return result diff --git a/src/langbot/pkg/core/app.py b/src/langbot/pkg/core/app.py index 8cc97f537..223bc1e67 100644 --- a/src/langbot/pkg/core/app.py +++ b/src/langbot/pkg/core/app.py @@ -47,7 +47,9 @@ from ..survey import manager as survey_module from ..skill import manager as skill_mgr from ..workspace import service as workspace_service_module from ..workspace import collaboration as workspace_collaboration_module +from ..workspace import invitation_delivery as invitation_delivery_module from ..cloud import bootstrap as cloud_bootstrap_module +from ..cloud import launch as cloud_launch_module from ..cloud import directory_projection as cloud_directory_projection_module from ..cloud import entitlements as cloud_entitlements_module from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType @@ -129,6 +131,10 @@ class Application: workspace_collaboration_service: workspace_collaboration_module.WorkspaceCollaborationService = None + invitation_delivery_service: invitation_delivery_module.InvitationDeliveryService = None + + space_launch_service: cloud_launch_module.SpaceLaunchService = None + deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None diff --git a/src/langbot/pkg/core/stages/build_app.py b/src/langbot/pkg/core/stages/build_app.py index 43881bb83..21b543409 100644 --- a/src/langbot/pkg/core/stages/build_app.py +++ b/src/langbot/pkg/core/stages/build_app.py @@ -39,7 +39,9 @@ from ...telemetry import telemetry as telemetry_module from ...survey import manager as survey_module from ...workspace import service as workspace_service_module from ...workspace import collaboration as workspace_collaboration_module +from ...workspace import invitation_delivery as invitation_delivery_module from ...cloud import bootstrap as cloud_bootstrap +from ...cloud import launch as cloud_launch_module from ...cloud.directory_projection import DirectoryProjectionService from ...cloud.entitlements import EntitlementResolver from ...api.http.context import ExecutionContext, PrincipalContext, PrincipalType @@ -169,6 +171,8 @@ class BuildAppStage(stage.BootingStage): ap, workspace_service_inst, ) + ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap) + ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap) user_service_inst = user_service.UserService(ap) ap.user_service = user_service_inst diff --git a/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py b/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py new file mode 100644 index 000000000..4a0646e07 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0015_cloud_core_collaboration.py @@ -0,0 +1,75 @@ +"""allow Core-owned collaboration writes on Cloud Workspaces + +Revision ID: 0015_cloud_core_collab +Revises: 0014_cloud_directory +Create Date: 2026-07-26 + +Cloud-projected Workspace identity remains projected by the directory +boundary, but membership role/remove and invitation acceptance are now owned +by Core tenant scope. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = '0015_cloud_core_collab' +down_revision = '0014_cloud_directory' +branch_labels = None +depends_on = None + + +_TABLE_NAME = 'workspace_memberships' +_POLICY_NAME = 'langbot_workspace_local_directory_write' +_TENANT_SETTING = 'langbot.workspace_uuid' + + +def _setting(name: str) -> str: + return f"NULLIF(current_setting('{name}', true), '')" + + +def _quote(conn: sa.Connection, identifier: str) -> str: + return conn.dialect.identifier_preparer.quote(identifier) + + +def _drop_policy(conn: sa.Connection) -> None: + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}')) + + +def _create_policy(conn: sa.Connection, expression: str) -> None: + table = _quote(conn, _TABLE_NAME) + policy = _quote(conn, _POLICY_NAME) + op.execute( + sa.text( + f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC ' + f'USING ({expression}) WITH CHECK ({expression})' + ) + ) + + +def upgrade() -> None: + conn = op.get_bind() + if conn.dialect.name != 'postgresql': + return + expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}' + _drop_policy(conn) + _create_policy(conn, expression) + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name != 'postgresql': + return + expression = ( + f'workspace_uuid::text = {_setting(_TENANT_SETTING)} AND EXISTS (' + 'SELECT 1 FROM workspaces AS local_workspace ' + 'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid ' + "AND local_workspace.source = 'local'" + ')' + ) + _drop_policy(conn) + _create_policy(conn, expression) diff --git a/src/langbot/pkg/persistence/mgr.py b/src/langbot/pkg/persistence/mgr.py index 3c11935de..537be7404 100644 --- a/src/langbot/pkg/persistence/mgr.py +++ b/src/langbot/pkg/persistence/mgr.py @@ -1500,10 +1500,7 @@ class PersistenceManager: f"(((uuid)::text = {setting(TENANT_SETTING)}) AND ((source)::text = 'local'::text))" ) local_membership_expression = ( - f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n' - ' FROM workspaces local_workspace\n' - ' WHERE (((local_workspace.uuid)::text = (workspace_memberships.workspace_uuid)::text) ' - "AND ((local_workspace.source)::text = 'local'::text)))))" + f'((workspace_uuid)::text = {setting(TENANT_SETTING)})' ) local_execution_expression = ( f'(((workspace_uuid)::text = {setting(TENANT_SETTING)}) AND (EXISTS ( SELECT 1\n' diff --git a/src/langbot/pkg/workspace/collaboration.py b/src/langbot/pkg/workspace/collaboration.py index a6a433392..b313d4222 100644 --- a/src/langbot/pkg/workspace/collaboration.py +++ b/src/langbot/pkg/workspace/collaboration.py @@ -21,7 +21,6 @@ from ..entity.persistence.workspace import ( Workspace, WorkspaceInvitation, WorkspaceMembership, - WorkspaceSource, WorkspaceStatus, ) from .entities import WorkspaceExecutionBinding @@ -319,7 +318,7 @@ class WorkspaceCollaborationService: session: AsyncSession | None = None, ) -> list[WorkspaceInvitation]: async def operation(active_session: AsyncSession) -> list[WorkspaceInvitation]: - await self._require_local_workspace(active_session, workspace_uuid) + await self._require_active_workspace(active_session, workspace_uuid) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor) self._require_member_manager(persisted_actor, workspace_uuid) await self._expire_pending_invitations(active_session, workspace_uuid=workspace_uuid) @@ -357,7 +356,7 @@ class WorkspaceCollaborationService: raise InvitationError('Invitation expiry must be in the future') async def operation(active_session: AsyncSession) -> CreatedInvitation: - await self._require_local_workspace(active_session, workspace_uuid) + await self._require_active_workspace(active_session, workspace_uuid) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True) self._require_member_manager(persisted_actor, workspace_uuid) existing_account = await active_session.scalar( @@ -430,8 +429,6 @@ class WorkspaceCollaborationService: workspace = await active_session.get(Workspace, invitation.workspace_uuid) if workspace is None or workspace.status != WorkspaceStatus.ACTIVE.value: raise InvitationError('The invitation Workspace is unavailable') - if workspace.source != WorkspaceSource.LOCAL.value: - raise InvitationError('Cloud invitations are managed by the SaaS control plane') return invitation, workspace return await self._run(operation, session=session) @@ -458,7 +455,7 @@ class WorkspaceCollaborationService: async def operation(active_session: AsyncSession) -> WorkspaceMembership: invitation = await self._get_invitation_by_token(active_session, token, for_update=True) self._validate_invitation_state(invitation) - await self._require_local_workspace(active_session, invitation.workspace_uuid) + await self._require_active_workspace(active_session, invitation.workspace_uuid) account = await active_session.scalar(sqlalchemy.select(User).where(User.uuid == account_uuid)) if account is None or account.status != AccountStatus.ACTIVE.value: raise MembershipNotFoundError('Account not found') @@ -531,7 +528,7 @@ class WorkspaceCollaborationService: session: AsyncSession | None = None, ) -> WorkspaceInvitation: async def operation(active_session: AsyncSession) -> WorkspaceInvitation: - await self._require_local_workspace(active_session, workspace_uuid) + await self._require_active_workspace(active_session, workspace_uuid) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True) self._require_member_manager(persisted_actor, workspace_uuid) invitation = await active_session.scalar( @@ -568,7 +565,7 @@ class WorkspaceCollaborationService: raise MembershipPermissionError('Unknown Workspace role') async def operation(active_session: AsyncSession) -> WorkspaceMembership: - await self._require_local_workspace(active_session, workspace_uuid) + await self._require_active_workspace(active_session, workspace_uuid) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True) self._require_member_manager(persisted_actor, workspace_uuid) target = await self._get_active_member_for_update( @@ -594,7 +591,7 @@ class WorkspaceCollaborationService: session: AsyncSession | None = None, ) -> WorkspaceMembership: async def operation(active_session: AsyncSession) -> WorkspaceMembership: - await self._require_local_workspace(active_session, workspace_uuid) + await self._require_active_workspace(active_session, workspace_uuid) persisted_actor = await self._load_actor(active_session, workspace_uuid, actor, for_update=True) self._require_member_manager(persisted_actor, workspace_uuid) target = await self._get_active_member_for_update( @@ -630,7 +627,7 @@ class WorkspaceCollaborationService: raise InvitationError('Invitation not found') return invitation - async def _require_local_workspace( + async def _require_active_workspace( self, session: AsyncSession, workspace_uuid: str, @@ -642,8 +639,6 @@ class WorkspaceCollaborationService: or workspace.status != WorkspaceStatus.ACTIVE.value ): raise WorkspaceNotFoundError('Workspace not found') - if workspace.source != WorkspaceSource.LOCAL.value: - raise MembershipPermissionError('Cloud Workspace directory changes are managed by the SaaS control plane') return workspace def _validate_invitation_state(self, invitation: WorkspaceInvitation) -> None: diff --git a/src/langbot/pkg/workspace/invitation_delivery.py b/src/langbot/pkg/workspace/invitation_delivery.py new file mode 100644 index 000000000..cea3f05f2 --- /dev/null +++ b/src/langbot/pkg/workspace/invitation_delivery.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import asyncio +import dataclasses +import os +import smtplib +import ssl +import typing +from email.message import EmailMessage +from urllib.parse import quote + +import httpx + +if typing.TYPE_CHECKING: + from ..core.app import Application + + +DeliveryStatus = typing.Literal['sent', 'link_only', 'failed'] + + +@dataclasses.dataclass(frozen=True, slots=True) +class InvitationDeliveryResult: + status: DeliveryStatus + provider: str | None + + def to_public_dict(self) -> dict[str, str | None]: + return {'status': self.status, 'provider': self.provider} + + +@dataclasses.dataclass(frozen=True, slots=True) +class _EmailConfig: + provider: typing.Literal['resend', 'smtp'] | None + sender: str + resend_api_key: str + resend_api_url: str + smtp_host: str + smtp_port: int + smtp_username: str + smtp_password: str + smtp_starttls: bool + smtp_ssl: bool + timeout: float + + +class InvitationDeliveryService: + """Optional Workspace invitation email delivery. + + The invitation link is always returned to the caller. Email failures are + reported as non-secret status and never invalidate the persisted invite. + """ + + def __init__(self, ap: Application) -> None: + self.ap = ap + + def capability(self) -> dict[str, str | bool | None]: + config = self._email_config() + return {'enabled': config.provider is not None, 'provider': config.provider} + + def build_invitation_link(self, token: str) -> str: + base_url = self._public_web_url().rstrip('/') + return f'{base_url}/invitations/accept#token={quote(token, safe="")}' + + async def deliver_invitation( + self, + *, + recipient_email: str, + workspace_name: str, + invitation_link: str, + ) -> InvitationDeliveryResult: + config = self._email_config() + if config.provider is None: + return InvitationDeliveryResult(status='link_only', provider=None) + + try: + if config.provider == 'resend': + sent = await self._send_resend(config, recipient_email, workspace_name, invitation_link) + else: + sent = await self._send_smtp(config, recipient_email, workspace_name, invitation_link) + except Exception as exc: + self._log_delivery_failure(config.provider, exc) + sent = False + + return InvitationDeliveryResult( + status='sent' if sent else 'failed', + provider=config.provider, + ) + + async def _send_resend( + self, + config: _EmailConfig, + recipient_email: str, + workspace_name: str, + invitation_link: str, + ) -> bool: + payload = { + 'from': config.sender, + 'to': [recipient_email], + 'subject': f'You were invited to {workspace_name}', + 'text': self._plain_text(workspace_name, invitation_link), + 'html': self._html(workspace_name, invitation_link), + } + async with httpx.AsyncClient(timeout=httpx.Timeout(config.timeout), trust_env=True) as client: + response = await client.post( + config.resend_api_url, + headers={'Authorization': f'Bearer {config.resend_api_key}'}, + json=payload, + ) + if response.status_code >= 400: + self._log_delivery_failure(config.provider or 'resend', RuntimeError(f'Resend returned {response.status_code}')) + return False + return True + + async def _send_smtp( + self, + config: _EmailConfig, + recipient_email: str, + workspace_name: str, + invitation_link: str, + ) -> bool: + message = EmailMessage() + message['From'] = config.sender + message['To'] = recipient_email + message['Subject'] = f'You were invited to {workspace_name}' + message.set_content(self._plain_text(workspace_name, invitation_link)) + message.add_alternative(self._html(workspace_name, invitation_link), subtype='html') + + return await asyncio.to_thread(self._send_smtp_sync, config, message) + + @staticmethod + def _send_smtp_sync(config: _EmailConfig, message: EmailMessage) -> bool: + smtp_cls = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP + context = ssl.create_default_context() + with smtp_cls(config.smtp_host, config.smtp_port, timeout=config.timeout) as smtp: + if config.smtp_starttls and not config.smtp_ssl: + smtp.starttls(context=context) + if config.smtp_username: + smtp.login(config.smtp_username, config.smtp_password) + smtp.send_message(message) + return True + + def _email_config(self) -> _EmailConfig: + data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} + email = ( + data.get('workspace', {}) + .get('invitations', {}) + .get('email', {}) + ) + if not isinstance(email, dict): + email = {} + raw_provider = self._env('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', email.get('provider', '')) + raw_provider = str(raw_provider or '').strip().casefold() + provider: typing.Literal['resend', 'smtp'] | None + provider = raw_provider if raw_provider in {'resend', 'smtp'} else None + sender = str(self._env('WORKSPACE__INVITATIONS__EMAIL__FROM', email.get('from', '')) or '').strip() + timeout = self._number( + self._env('WORKSPACE__INVITATIONS__EMAIL__TIMEOUT_SECONDS', email.get('timeout_seconds', 10)), + 10.0, + ) + + resend = email.get('resend', {}) + if not isinstance(resend, dict): + resend = {} + smtp_config = email.get('smtp', {}) + if not isinstance(smtp_config, dict): + smtp_config = {} + + resend_api_key = str( + self._env('WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY', resend.get('api_key', '')) or '' + ).strip() + resend_api_url = str( + self._env( + 'WORKSPACE__INVITATIONS__EMAIL__RESEND__API_URL', + resend.get('api_url', 'https://api.resend.com/emails'), + ) + or '' + ).strip() + smtp_host = str( + self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', smtp_config.get('host', '')) or '' + ).strip() + smtp_port = int( + self._number(self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PORT', smtp_config.get('port', 587)), 587) + ) + smtp_username = str( + self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__USERNAME', smtp_config.get('username', '')) or '' + ).strip() + smtp_password = str( + self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', smtp_config.get('password', '')) or '' + ) + smtp_starttls = self._bool( + self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__STARTTLS', smtp_config.get('starttls', True)) + ) + smtp_ssl = self._bool( + self._env('WORKSPACE__INVITATIONS__EMAIL__SMTP__SSL', smtp_config.get('ssl', False)) + ) + + if provider == 'resend' and not (sender and resend_api_key and resend_api_url): + provider = None + elif provider == 'smtp' and not (sender and smtp_host): + provider = None + + return _EmailConfig( + provider=provider, + sender=sender, + resend_api_key=resend_api_key, + resend_api_url=resend_api_url, + smtp_host=smtp_host, + smtp_port=smtp_port, + smtp_username=smtp_username, + smtp_password=smtp_password, + smtp_starttls=smtp_starttls, + smtp_ssl=smtp_ssl, + timeout=timeout, + ) + + def _public_web_url(self) -> str: + data = getattr(getattr(self.ap, 'instance_config', None), 'data', {}) or {} + invitations = data.get('workspace', {}).get('invitations', {}) + configured = '' + if isinstance(invitations, dict): + configured = str( + self._env('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', invitations.get('public_web_url', '')) or '' + ).strip() + if configured: + return configured + api = data.get('api', {}) + if isinstance(api, dict): + webui_url = str(api.get('webui_url', '') or '').strip() + if webui_url: + return webui_url + webhook_prefix = str(api.get('webhook_prefix', '') or '').strip() + if webhook_prefix: + return webhook_prefix + port = api.get('port', 5300) + else: + port = 5300 + return f'http://127.0.0.1:{port}' + + @staticmethod + def _plain_text(workspace_name: str, invitation_link: str) -> str: + return ( + f'You were invited to join {workspace_name} on LangBot.\n\n' + f'Open this secure invitation link to continue:\n{invitation_link}\n' + ) + + @staticmethod + def _html(workspace_name: str, invitation_link: str) -> str: + escaped_workspace = ( + workspace_name.replace('&', '&').replace('<', '<').replace('>', '>') + ) + escaped_link = ( + invitation_link.replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + ) + return ( + '

You were invited to join ' + f'{escaped_workspace} on LangBot.

' + f'

Accept the invitation

' + f'

{escaped_link}

' + ) + + @staticmethod + def _number(value: typing.Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _bool(value: typing.Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {'true', '1', 'yes', 'on'} + return bool(value) + + @staticmethod + def _env(name: str, fallback: typing.Any) -> typing.Any: + value = os.environ.get(name) + if value is None: + return fallback + return value + + def _log_delivery_failure(self, provider: str, exc: Exception) -> None: + logger = getattr(self.ap, 'logger', None) + if logger is not None: + logger.warning(f'Workspace invitation email delivery via {provider} failed: {exc.__class__.__name__}') diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index fc46aac98..23f5aa679 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -14,6 +14,30 @@ api: # login session and without a database record. Leave empty to disable. # Keep this value secret; only enable it on trusted/internal deployments. global_api_key: '' +workspace: + invitations: + # Public WebUI origin used to build invitation links. Leave empty to + # use api.webui_url, then api.webhook_prefix. Set via + # WORKSPACE__INVITATIONS__PUBLIC_WEB_URL in container deployments. + public_web_url: '' + email: + # Optional invitation email delivery. Empty provider keeps + # invitations link-only. Supported: resend, smtp. + provider: '' + from: '' + timeout_seconds: 10 + resend: + api_url: 'https://api.resend.com/emails' + # Secret. Set via WORKSPACE__INVITATIONS__EMAIL__RESEND__API_KEY. + api_key: '' + smtp: + host: '' + port: 587 + username: '' + # Secret. Set via WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD. + password: '' + starttls: true + ssl: false command: enable: true prefix: diff --git a/tests/integration/api/test_user_space_oauth.py b/tests/integration/api/test_user_space_oauth.py index 2c194f1f6..a662a715e 100644 --- a/tests/integration/api/test_user_space_oauth.py +++ b/tests/integration/api/test_user_space_oauth.py @@ -46,10 +46,17 @@ async def space_oauth_api(): local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None ) ) + application.user_service.consume_space_oauth_state_details = AsyncMock( + return_value=SimpleNamespace(launch_workspace_uuid=None) + ) application.user_service.bind_space_account = AsyncMock(return_value=bound_account) application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token') + application.user_service.get_user_by_uuid = AsyncMock(return_value=bound_account) application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account)) application.user_service.verify_jwt_token = AsyncMock() + application.space_launch_service.consume_assertion = AsyncMock( + return_value={'account_uuid': 'account-a', 'workspace_uuid': WORKSPACE_UUID} + ) application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access) application.space_service.get_oauth_authorize_url = Mock( side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}' @@ -87,6 +94,29 @@ async def test_public_login_state_is_server_issued(space_oauth_api): application.user_service.issue_space_oauth_state.assert_awaited_once_with('login') +@pytest.mark.asyncio +async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oauth_api): + application, client = space_oauth_api + application.deployment.multi_workspace_enabled = True + + response = await client.get( + '/api/v1/user/space/authorize-url', + query_string={ + 'redirect_uri': 'http://localhost/auth/space/callback', + 'launch_workspace_uuid': WORKSPACE_UUID, + }, + headers={'Origin': 'http://localhost'}, + ) + + assert response.status_code == 200 + authorize_url = (await response.get_json())['data']['authorize_url'] + assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state'] + application.user_service.issue_space_oauth_state.assert_awaited_once_with( + 'login', + launch_workspace_uuid=WORKSPACE_UUID, + ) + + @pytest.mark.asyncio async def test_public_login_rejects_caller_supplied_state(space_oauth_api): application, client = space_oauth_api @@ -203,10 +233,33 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api assert (await missing.get_json())['code'] == 1 assert response.status_code == 200 assert (await response.get_json())['data']['token'] == 'space-login-token' - application.user_service.consume_space_oauth_state.assert_awaited_once_with('opaque-login-state', 'login') + application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login') application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code') +@pytest.mark.asyncio +async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api): + application, client = space_oauth_api + application.user_service.consume_space_oauth_state_details.reset_mock() + application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace( + launch_workspace_uuid=WORKSPACE_UUID + ) + + response = await client.post( + '/api/v1/user/space/callback', + json={'code': 'oauth-code', 'state': 'opaque-login-state'}, + ) + + assert response.status_code == 200 + data = (await response.get_json())['data'] + assert data['token'] == 'space-login-token' + assert data['workspace_uuid'] == WORKSPACE_UUID + application.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with( + 'account-a', + WORKSPACE_UUID, + ) + + @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 @@ -234,3 +287,30 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_ assert (await response.get_json())['data']['token'] == 'rotated-account-token' application.user_service.verify_jwt_token.assert_not_awaited() application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code') + + +@pytest.mark.asyncio +async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api): + application, client = space_oauth_api + application.user_service.consume_space_oauth_state.reset_mock() + application.space_service.exchange_oauth_code.reset_mock() + + response = await client.post( + '/api/v1/user/space/callback', + json={ + 'state': 'space-generated-state-is-not-oauth-state', + 'workspace_uuid': WORKSPACE_UUID, + 'launch_assertion': 'signed-launch-token', + }, + ) + + assert response.status_code == 200 + data = (await response.get_json())['data'] + assert data['token'] == 'rotated-account-token' + assert data['workspace_uuid'] == WORKSPACE_UUID + application.space_launch_service.consume_assertion.assert_awaited_once_with( + 'signed-launch-token', + expected_workspace_uuid=WORKSPACE_UUID, + ) + application.user_service.consume_space_oauth_state.assert_not_awaited() + application.space_service.exchange_oauth_code.assert_not_awaited() diff --git a/tests/integration/api/test_workspaces.py b/tests/integration/api/test_workspaces.py index 9ec261870..005df2dfc 100644 --- a/tests/integration/api/test_workspaces.py +++ b/tests/integration/api/test_workspaces.py @@ -53,7 +53,7 @@ async def workspace_api(tmp_path): 'jwt': {'secret': 'workspace-api-secret', 'expire': 3600}, 'allow_modify_login_info': True, }, - 'api': {'global_api_key': ''}, + 'api': {'global_api_key': '', 'webui_url': 'https://langbot.example'}, } ) application.logger = logging.getLogger('workspace-api-test') @@ -198,6 +198,8 @@ async def test_owner_invites_second_account_and_secret_is_not_persisted(workspac invite_data = (await invite_response.get_json())['data'] invitation_token = invite_data['token'] assert invitation_token.startswith('lbi_') + assert invite_data['link'] == f'https://langbot.example/invitations/accept#token={invitation_token}' + assert invite_data['delivery'] == {'status': 'link_only', 'provider': None} assert 'token_hash' not in invite_data['invitation'] async with engine.connect() as connection: @@ -376,7 +378,7 @@ async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspac assert (await forbidden.get_json())['code'] == 'permission_denied' -async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_control_plane( +async def test_cloud_projection_is_selected_explicitly_and_collaboration_runs_in_core( workspace_api, ): application, client, engine, owner_token = workspace_api @@ -514,8 +516,42 @@ async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_ headers=_auth(owner_token, cloud_workspace_uuid), json={'email': 'member@example.com', 'role': 'viewer'}, ) - assert create_invitation.status_code == 409 - assert (await create_invitation.get_json())['code'] == 'control_plane_required' + assert create_invitation.status_code == 200 + created_invitation = (await create_invitation.get_json())['data'] + assert created_invitation['invitation']['workspace_uuid'] == cloud_workspace_uuid + assert created_invitation['link'].startswith('https://langbot.example/invitations/accept#token=lbi_') + assert created_invitation['delivery'] == {'status': 'link_only', 'provider': None} + + accept_response = await client.post( + '/api/v1/invitations/accept', + json={ + 'token': created_invitation['token'], + 'registration': {'email': 'member@example.com', 'password': 'member-password'}, + }, + ) + assert accept_response.status_code == 200 + member_token = (await accept_response.get_json())['data']['token'] + + member_current = await client.get( + '/api/v1/workspaces/current', + headers=_auth(member_token, cloud_workspace_uuid), + ) + assert member_current.status_code == 200 + member_account_uuid = (await member_current.get_json())['data']['membership']['account_uuid'] + + update_member = await client.patch( + f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}', + headers=_auth(owner_token, cloud_workspace_uuid), + json={'role': 'developer'}, + ) + assert update_member.status_code == 200 + assert (await update_member.get_json())['data']['member']['role'] == 'developer' + + remove_member = await client.delete( + f'/api/v1/workspaces/{cloud_workspace_uuid}/members/{member_account_uuid}', + headers=_auth(owner_token, cloud_workspace_uuid), + ) + assert remove_member.status_code == 200 async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api): diff --git a/tests/integration/persistence/test_migrations_postgres.py b/tests/integration/persistence/test_migrations_postgres.py index 87caee109..023a0de11 100644 --- a/tests/integration/persistence/test_migrations_postgres.py +++ b/tests/integration/persistence/test_migrations_postgres.py @@ -973,7 +973,7 @@ class TestPostgreSQLTenantRuntime: .values(write_fenced=True) ) assert workspace_update.rowcount == 0 - assert membership_update.rowcount == 0 + assert membership_update.rowcount == 1 assert execution_update.rowcount == 0 async with cloud_manager.tenant_uow(workspace_local) as tenant: diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index eb1516520..5024ee6cb 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -64,12 +64,29 @@ class TestSpaceOAuthState: service = UserService(SimpleNamespace()) state = await service.issue_space_oauth_state('login') digest = service._space_oauth_state_digest(state) - purpose, account_uuid, _ = service._space_oauth_states[digest] - service._space_oauth_states[digest] = (purpose, account_uuid, 0) + purpose, account_uuid, _, launch_workspace_uuid = service._space_oauth_states[digest] + service._space_oauth_states[digest] = (purpose, account_uuid, 0, launch_workspace_uuid) with pytest.raises(ValueError, match='Invalid or expired OAuth state'): await service.consume_space_oauth_state(state, 'login') + async def test_login_state_can_carry_launch_workspace_without_changing_normal_return(self): + service = UserService(SimpleNamespace()) + state = await service.issue_space_oauth_state( + 'login', + launch_workspace_uuid='workspace-a', + ) + + assert await service.consume_space_oauth_state(state, 'login') is None + + state = await service.issue_space_oauth_state( + 'login', + launch_workspace_uuid='workspace-a', + ) + consumed = await service.consume_space_oauth_state_details(state, 'login') + assert consumed.account is None + assert consumed.launch_workspace_uuid == 'workspace-a' + def _create_mock_user( email: str = 'test@example.com', diff --git a/tests/unit_tests/cloud/test_space_launch.py b/tests/unit_tests/cloud/test_space_launch.py new file mode 100644 index 000000000..feca2ac65 --- /dev/null +++ b/tests/unit_tests/cloud/test_space_launch.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import base64 +import json +import time +import uuid +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService + + +pytestmark = pytest.mark.asyncio + + +INSTANCE_UUID = 'instance-test' +ACCOUNT_UUID = '11111111-1111-4111-8111-111111111111' +WORKSPACE_UUID = '22222222-2222-4222-8222-222222222222' +KEY_ID = 'space-key-1' + + +def _base64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii') + + +def _sign(private_key: Ed25519PrivateKey, claims: dict, *, key_id: str = KEY_ID) -> str: + header = {'alg': 'EdDSA', 'kid': key_id, 'typ': 'langbot-control-plane+jwt'} + encoded_header = _base64url(json.dumps(header, separators=(',', ':')).encode('utf-8')) + encoded_claims = _base64url(json.dumps(claims, separators=(',', ':')).encode('utf-8')) + signing_input = f'{encoded_header}.{encoded_claims}' + return f'{signing_input}.{_base64url(private_key.sign(signing_input.encode("ascii")))}' + + +def _claims(*, now: int, jti: str | None = None, workspace_uuid: str = WORKSPACE_UUID) -> dict: + return { + 'iss': 'langbot-space', + 'aud': 'langbot-cloud-runtime', + 'sub': f'langbot-instance:{INSTANCE_UUID}', + 'jti': jti or str(uuid.uuid4()), + 'iat': now, + 'nbf': now - 5, + 'exp': now + 90, + 'instance_uuid': INSTANCE_UUID, + 'kind': 'workspace.launch', + 'payload': { + 'account_uuid': ACCOUNT_UUID, + 'workspace_uuid': workspace_uuid, + }, + } + + +def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService: + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + app = SimpleNamespace( + deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID), + workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID), + instance_config=SimpleNamespace( + data={ + 'space': { + 'launch': { + 'control_plane_public_key': _base64url(public_key), + } + } + } + ), + ) + return SpaceLaunchService(app, wall_time=lambda: now) + + +async def test_consumes_valid_workspace_launch_assertion_once(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + token = _sign(private_key, _claims(now=now)) + + launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + assert launch == {'account_uuid': ACCOUNT_UUID, 'workspace_uuid': WORKSPACE_UUID} + with pytest.raises(SpaceLaunchError, match='already been consumed'): + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + +async def test_rejects_expired_wrong_workspace_and_wrong_instance_assertions(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + service = _service(private_key, now=now) + + expired = _claims(now=now) + expired['exp'] = now - 60 + with pytest.raises(SpaceLaunchError, match='expired'): + await service.consume_assertion(_sign(private_key, expired), expected_workspace_uuid=WORKSPACE_UUID) + + wrong_workspace = _sign(private_key, _claims(now=now, workspace_uuid='33333333-3333-4333-8333-333333333333')) + with pytest.raises(SpaceLaunchError, match='another Workspace'): + await service.consume_assertion(wrong_workspace, expected_workspace_uuid=WORKSPACE_UUID) + + wrong_instance = _claims(now=now) + wrong_instance['instance_uuid'] = 'other-instance' + with pytest.raises(SpaceLaunchError, match='instance UUID'): + await service.consume_assertion(_sign(private_key, wrong_instance), expected_workspace_uuid=WORKSPACE_UUID) + + +async def test_rejects_invalid_signature_and_non_cloud_mode(): + private_key = Ed25519PrivateKey.generate() + now = int(time.time()) + token = _sign(private_key, _claims(now=now)) + service = _service(Ed25519PrivateKey.generate(), now=now) + with pytest.raises(SpaceLaunchError, match='signature'): + await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) + + oss_service = _service(private_key, now=now) + oss_service.ap.deployment.multi_workspace_enabled = False + with pytest.raises(SpaceLaunchError, match='verified Cloud mode'): + await oss_service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID) diff --git a/tests/unit_tests/workspace/test_invitation_delivery.py b/tests/unit_tests/workspace/test_invitation_delivery.py new file mode 100644 index 000000000..cd45c21d1 --- /dev/null +++ b/tests/unit_tests/workspace/test_invitation_delivery.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.workspace.invitation_delivery import ( + InvitationDeliveryResult, + InvitationDeliveryService, +) + + +pytestmark = pytest.mark.asyncio + + +def _app(config: dict) -> SimpleNamespace: + return SimpleNamespace( + instance_config=SimpleNamespace(data=config), + logger=SimpleNamespace(warning=lambda *args, **kwargs: None), + ) + + +async def test_link_only_delivery_builds_public_web_link_without_secret_capability(): + service = InvitationDeliveryService( + _app( + { + 'api': { + 'webui_url': 'https://public.langbot.example/', + 'webhook_prefix': 'http://internal:5300', + } + } + ) + ) + + assert service.build_invitation_link('lbi_secret') == ( + 'https://public.langbot.example/invitations/accept#token=lbi_secret' + ) + assert service.capability() == {'enabled': False, 'provider': None} + result = await service.deliver_invitation( + recipient_email='member@example.com', + workspace_name='Workspace', + invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret', + ) + assert result == InvitationDeliveryResult(status='link_only', provider=None) + + +async def test_configured_provider_failure_returns_failed_without_raising(): + service = InvitationDeliveryService( + _app( + { + 'workspace': { + 'invitations': { + 'email': { + 'provider': 'resend', + 'from': 'LangBot ', + 'resend': {'api_key': 'resend-secret'}, + } + } + } + } + ) + ) + service._send_resend = AsyncMock(return_value=False) + + assert service.capability() == {'enabled': True, 'provider': 'resend'} + result = await service.deliver_invitation( + recipient_email='member@example.com', + workspace_name='Workspace', + invitation_link='https://public.langbot.example/invitations/accept#token=lbi_secret', + ) + + assert result == InvitationDeliveryResult(status='failed', provider='resend') + service._send_resend.assert_awaited_once() + + +async def test_environment_mapping_enables_provider_without_leaking_secret(monkeypatch): + monkeypatch.setenv('WORKSPACE__INVITATIONS__PUBLIC_WEB_URL', 'https://env.langbot.example/') + monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__PROVIDER', 'smtp') + monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__FROM', 'LangBot ') + monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__HOST', 'smtp.example.com') + monkeypatch.setenv('WORKSPACE__INVITATIONS__EMAIL__SMTP__PASSWORD', 'smtp-secret') + service = InvitationDeliveryService(_app({'api': {'webui_url': 'https://config.example'}})) + + assert service.build_invitation_link('lbi_secret') == ( + 'https://env.langbot.example/invitations/accept#token=lbi_secret' + ) + assert service.capability() == {'enabled': True, 'provider': 'smtp'} diff --git a/tests/unit_tests/workspace/test_workspace_collaboration.py b/tests/unit_tests/workspace/test_workspace_collaboration.py index 2f657e0c4..a5fbb7bca 100644 --- a/tests/unit_tests/workspace/test_workspace_collaboration.py +++ b/tests/unit_tests/workspace/test_workspace_collaboration.py @@ -240,7 +240,7 @@ async def test_cloud_member_listing_opens_workspace_uow_when_request_scope_has_c ] -async def test_cloud_directory_requires_explicit_selector_and_rejects_local_mutation( +async def test_cloud_directory_requires_explicit_selector_and_allows_core_membership_management( collaboration_context, ): _service, workspace_service, session_factory, owner, _workspace, _membership = collaboration_context @@ -292,13 +292,34 @@ async def test_cloud_directory_requires_explicit_selector_and_rejects_local_muta assert access.workspace.uuid == cloud_workspace_uuid assert access.execution.placement_generation == 8 - with pytest.raises(MembershipPermissionError, match='control plane'): - await cloud_service.create_invitation( - cloud_workspace_uuid, - cloud_membership, - 'member@example.com', - 'viewer', - ) + created = await cloud_service.create_invitation( + cloud_workspace_uuid, + cloud_membership, + 'member@example.com', + 'viewer', + ) + assert created.invitation.workspace_uuid == cloud_workspace_uuid + assert created.token.startswith('lbi_') + + member = await _add_account(session_factory, 'member@example.com') + membership = await cloud_service.accept_invitation(created.token, member.uuid) + assert membership.workspace_uuid == cloud_workspace_uuid + assert membership.role == 'viewer' + + updated = await cloud_service.update_member_role( + cloud_workspace_uuid, + member.uuid, + 'developer', + cloud_membership, + ) + assert updated.role == 'developer' + + removed = await cloud_service.remove_member( + cloud_workspace_uuid, + member.uuid, + cloud_membership, + ) + assert removed.status == 'removed' async def test_invitation_lock_registry_does_not_retain_sequential_tokens(collaboration_context): diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 19a9a710c..68f0a810d 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -27,6 +27,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp'; type SpaceOAuthLoginResult = { token: string; user: string; + workspace_uuid?: string; }; const pendingSpaceOAuthLogins = new Map< @@ -37,15 +38,17 @@ const pendingSpaceOAuthLogins = new Map< function getOrCreateSpaceOAuthLoginPromise( authCode: string, state: string, + workspaceUuid?: string, + launchAssertion?: string, ): Promise { - const requestKey = `${authCode}:${state}`; + const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`; const pendingRequest = pendingSpaceOAuthLogins.get(requestKey); if (pendingRequest) { return pendingRequest; } const requestPromise = httpClient - .exchangeSpaceOAuthCode(authCode, state) + .exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion) .finally(() => { pendingSpaceOAuthLogins.delete(requestKey); }); @@ -70,21 +73,34 @@ function SpaceOAuthCallbackContent() { const [localEmail, setLocalEmail] = useState(''); const handleOAuthCallback = useCallback( - async (authCode: string, state: string) => { + async ( + authCode: string, + state: string, + workspaceUuid?: string, + launchAssertion?: string, + ) => { try { const response = await getOrCreateSpaceOAuthLoginPromise( authCode, state, + workspaceUuid, + launchAssertion, ); if (!isMountedRef.current) { return; } beginAuthenticatedSession(response.token, response.user); - const workspaceResult = await bootstrapWorkspaceSession(); + const workspaceResult = await bootstrapWorkspaceSession({ + preferredWorkspaceUuid: response.workspace_uuid, + }); if (workspaceResult.status === 'unavailable') { throw new Error('No Workspace is available for this Account'); } + if (response.workspace_uuid) { + navigate('/home', { replace: true }); + return; + } setStatus('success'); toast.success(t('common.spaceLoginSuccess')); @@ -171,6 +187,8 @@ function SpaceOAuthCallbackContent() { const errorDescription = searchParams.get('error_description'); const mode = searchParams.get('mode'); const state = searchParams.get('state'); + const workspaceUuid = searchParams.get('workspace_uuid'); + const launchAssertion = searchParams.get('launch_assertion'); if (error) { setStatus('error'); @@ -180,15 +198,13 @@ function SpaceOAuthCallbackContent() { return; } - if (!authCode) { - setStatus('error'); - setErrorMessage(t('common.spaceLoginNoCode')); - return; - } - - setCode(authCode); - if (mode === 'bind') { + if (!authCode) { + setStatus('error'); + setErrorMessage(t('common.spaceLoginNoCode')); + return; + } + setCode(authCode); // Bind mode - verify state (token) exists if (!state) { setStatus('error'); @@ -199,7 +215,20 @@ function SpaceOAuthCallbackContent() { setIsBindMode(true); setLocalEmail(localStorage.getItem('userEmail') || ''); setStatus('confirm'); + } else if (workspaceUuid || launchAssertion) { + if (!workspaceUuid || !launchAssertion) { + setStatus('error'); + setErrorMessage(t('common.spaceLoginFailed')); + return; + } + handleOAuthCallback(authCode ?? '', state ?? '', workspaceUuid, launchAssertion); } else { + if (!authCode) { + setStatus('error'); + setErrorMessage(t('common.spaceLoginNoCode')); + return; + } + setCode(authCode); if (!state) { setStatus('error'); setErrorMessage(t('common.spaceLoginFailed')); diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx index 5a58bd0bc..ebe184040 100644 --- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx +++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx @@ -76,20 +76,10 @@ export default function WorkspaceSettingsPanel({ const isCloudProjection = workspaceInfo?.workspace.source === 'cloud_projection'; const canViewMembers = permissions.has('member.view'); - const canInvite = !isCloudProjection && permissions.has('member.invite'); - const canUpdateMembers = - !isCloudProjection && permissions.has('member.update_role'); - const canRemoveMembers = - !isCloudProjection && permissions.has('member.remove'); - const canTransferOwner = - !isCloudProjection && permissions.has('owner.transfer'); - const canManageCloudMembers = - isCloudProjection && - (workspaceInfo?.membership.role === 'owner' || - workspaceInfo?.membership.role === 'admin'); - const cloudMembersURL = workspaceInfo - ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members` - : ''; + const canInvite = permissions.has('member.invite'); + const canUpdateMembers = permissions.has('member.update_role'); + const canRemoveMembers = permissions.has('member.remove'); + const canTransferOwner = permissions.has('owner.transfer'); const cloudPortalURL = workspaceInfo ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan` : ''; @@ -104,7 +94,6 @@ export default function WorkspaceSettingsPanel({ current.permissions.includes('member.view') ? backendClient.getWorkspaceMembers(current.workspace.uuid) : Promise.resolve({ members: [] }), - current.workspace.source === 'local' && current.permissions.includes('member.invite') ? backendClient.getWorkspaceInvitations(current.workspace.uuid) : Promise.resolve({ invitations: [] }), @@ -131,11 +120,10 @@ export default function WorkspaceSettingsPanel({ inviteEmail.trim(), inviteRole, ); - const link = `${window.location.origin}/invitations/accept#token=${encodeURIComponent(response.token)}`; - setOneTimeInviteLink(link); + setOneTimeInviteLink(response.link); setInviteEmail(''); await loadWorkspace(); - toast.success(t('workspace.invitationCreated')); + toast.success(t(`workspace.delivery.${response.delivery.status}`)); } catch { toast.error(t('workspace.invitationCreateFailed')); } finally { @@ -310,19 +298,6 @@ export default function WorkspaceSettingsPanel({

{t('workspace.members')}

- {canManageCloudMembers && ( - - )}
{members.map((member) => { diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 6373a5e09..4c4504707 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -352,6 +352,10 @@ export interface ApiRespSystemInfo { enable_marketplace: boolean; allow_modify_login_info: boolean; disable_models_service: boolean; + invitation_delivery?: { + enabled: boolean; + provider: 'resend' | 'smtp' | null; + }; limitation: SystemLimitation; /** Public outbound IPs of the deployment (``system.outbound_ips`` in * config.yaml). Shown on adapter config forms whose platform requires diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts index f3866882c..1806e63d8 100644 --- a/web/src/app/infra/entities/workspace.ts +++ b/web/src/app/infra/entities/workspace.ts @@ -51,3 +51,10 @@ export interface WorkspaceInvitation { expires_at: string; created_at: string; } + +export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed'; + +export interface WorkspaceInvitationDelivery { + status: WorkspaceInvitationDeliveryStatus; + provider: 'resend' | 'smtp' | null; +} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 04feaa932..716077f9c 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -65,6 +65,7 @@ import type { CurrentWorkspace, Workspace, WorkspaceInvitation, + WorkspaceInvitationDelivery, WorkspaceMembership, WorkspaceBootstrapResponse, WorkspaceRole, @@ -1192,7 +1193,12 @@ export class BackendClient extends BaseHttpClient { workspaceUuid: string, email: string, role: Exclude, - ): Promise<{ invitation: WorkspaceInvitation; token: string }> { + ): Promise<{ + invitation: WorkspaceInvitation; + token: string; + link: string; + delivery: WorkspaceInvitationDelivery; + }> { return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, { email, role, @@ -1318,13 +1324,21 @@ export class BackendClient extends BaseHttpClient { public async exchangeSpaceOAuthCode( code: string, state: string, + workspaceUuid?: string, + launchAssertion?: string, ): Promise<{ token: string; user: string; + workspace_uuid?: string; }> { const response = await this.instance.post( '/api/v1/user/space/callback', - { code, state }, + { + code, + state, + workspace_uuid: workspaceUuid, + launch_assertion: launchAssertion, + }, { skipWorkspace: true } as RequestConfig, ); if (response.data.code !== 0) { diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index d718cfb5c..6223144bb 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -30,6 +30,10 @@ export const systemInfo: ApiRespSystemInfo = { cloud_service_url: '', allow_modify_login_info: true, disable_models_service: false, + invitation_delivery: { + enabled: false, + provider: null, + }, limitation: { max_bots: -1, max_pipelines: -1, diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 87c8d39f5..e635f5f3d 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1300,7 +1300,7 @@ const enUS = { ossSingletonDescription: 'This self-hosted instance has one Workspace and can include multiple users.', cloudManagedDescription: - 'Membership, invitations, and billing for this Workspace are managed in the LangBot Cloud portal.', + 'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.', loadFailed: 'Failed to load Workspace information', members: 'Members', you: 'You', @@ -1310,9 +1310,13 @@ const enUS = { emailPlaceholder: 'member@example.com', createInvitation: 'Create invitation', invitationCreated: 'Invitation created', + delivery: { + sent: 'Invitation sent', + link_only: 'Invitation link created', + failed: 'Invitation link created, but email could not be sent', + }, invitationCreateFailed: 'Failed to create invitation', - oneTimeLinkWarning: - 'Copy this link now. The secret is shown only once and is not stored by LangBot.', + oneTimeLinkWarning: 'Copy this link now. It is shown only once.', copyInvitation: 'Copy invitation link', invitationCopied: 'Invitation link copied', pendingInvitations: 'Pending invitations', @@ -1323,7 +1327,7 @@ const enUS = { acceptInvitation: 'Accept invitation', invitedToWorkspace: 'You were invited to {{workspace}}', checkingInvitation: 'Checking this invitation...', - invitationMissing: 'The invitation secret is missing from this link.', + invitationMissing: 'This invitation link is missing required information.', invitationExpired: 'This invitation has expired.', invitationAlreadyRevoked: 'This invitation was revoked.', invitationAlreadyUsed: 'This invitation was already used.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 30e65e2c0..a0b816347 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1301,7 +1301,7 @@ const jaJP = { ossSingletonDescription: 'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。', cloudManagedDescription: - 'このワークスペースのメンバー、招待、請求は LangBot Cloud ポータルで管理されます。', + 'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。', loadFailed: 'ワークスペース情報の読み込みに失敗しました', members: 'メンバー', you: 'あなた', @@ -1311,9 +1311,14 @@ const jaJP = { emailPlaceholder: 'member@example.com', createInvitation: '招待を作成', invitationCreated: '招待を作成しました', + delivery: { + sent: '招待メールを送信しました', + link_only: '招待リンクを作成しました', + failed: '招待リンクを作成しましたが、メールを送信できませんでした', + }, invitationCreateFailed: '招待の作成に失敗しました', oneTimeLinkWarning: - 'このリンクを今すぐコピーしてください。シークレットは一度だけ表示され、LangBotには保存されません。', + 'このリンクを今すぐコピーしてください。一度だけ表示されます。', copyInvitation: '招待リンクをコピー', invitationCopied: '招待リンクをコピーしました', pendingInvitations: '保留中の招待', @@ -1324,7 +1329,7 @@ const jaJP = { acceptInvitation: '招待を承認', invitedToWorkspace: '{{workspace}} に招待されました', checkingInvitation: '招待を確認しています...', - invitationMissing: '招待リンクにシークレットがありません。', + invitationMissing: 'この招待リンクには必要な情報がありません。', invitationExpired: 'この招待は期限切れです。', invitationAlreadyRevoked: 'この招待は取り消されました。', invitationAlreadyUsed: 'この招待はすでに使用されています。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 70314f2b2..a070e9436 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1234,7 +1234,7 @@ const zhHans = { ossSingletonDescription: '当前自托管实例只有一个工作区,但可以包含多个用户。', cloudManagedDescription: - '此工作区的成员、邀请与计费由 LangBot Cloud 控制台统一管理。', + '此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。', loadFailed: '加载工作区信息失败', members: '成员', you: '你', @@ -1243,9 +1243,13 @@ const zhHans = { emailPlaceholder: 'member@example.com', createInvitation: '创建邀请', invitationCreated: '邀请已创建', + delivery: { + sent: '邀请邮件已发送', + link_only: '邀请链接已创建', + failed: '邀请链接已创建,但邮件发送失败', + }, invitationCreateFailed: '创建邀请失败', - oneTimeLinkWarning: - '请立即复制此链接。密钥只显示一次,LangBot 不会保存明文。', + oneTimeLinkWarning: '请立即复制此链接。它只显示一次。', copyInvitation: '复制邀请链接', invitationCopied: '邀请链接已复制', pendingInvitations: '待接受邀请', @@ -1256,7 +1260,7 @@ const zhHans = { acceptInvitation: '接受邀请', invitedToWorkspace: '你已受邀加入 {{workspace}}', checkingInvitation: '正在验证邀请…', - invitationMissing: '邀请链接中缺少密钥。', + invitationMissing: '此邀请链接缺少必要信息。', invitationExpired: '此邀请已过期。', invitationAlreadyRevoked: '此邀请已被撤销。', invitationAlreadyUsed: '此邀请已被使用。', diff --git a/web/tests/unit/workspace-switcher-layout.test.mjs b/web/tests/unit/workspace-switcher-layout.test.mjs index ee02b3fc7..750df5236 100644 --- a/web/tests/unit/workspace-switcher-layout.test.mjs +++ b/web/tests/unit/workspace-switcher-layout.test.mjs @@ -62,10 +62,11 @@ test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it ); }); -test('links Cloud workspace managers to the Space member invitation surface', () => { - assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/); - assert.match(workspaceSettingsPanelSource, /#workspace-members/); +test('keeps Cloud workspace member management in Workspace Settings', () => { assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/); + assert.doesNotMatch(workspaceSettingsPanelSource, /#workspace-members/); + assert.doesNotMatch(workspaceSettingsPanelSource, /cloudMembersURL/); + assert.doesNotMatch(workspaceSettingsPanelSource, /canManageCloudMembers/); }); test('keeps workspace plan and settings controls on the workspace row', () => { @@ -85,13 +86,7 @@ test('moves Cloud plan upgrades into Workspace Settings', () => { assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/); assert.match(workspaceSettingsPanelSource, /cloudPortalURL/); assert.match(workspaceSettingsPanelSource, /step=plan/); - const toolbarEnd = workspaceSettingsPanelSource.indexOf(''); - const membersHeading = - workspaceSettingsPanelSource.indexOf('workspace.members'); - const cloudInvite = workspaceSettingsPanelSource.indexOf( - 'href={cloudMembersURL}', - ); - assert.ok(toolbarEnd < membersHeading && membersHeading < cloudInvite); + assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/); }); test('aligns the workspace trigger with navigation entries on both sides and truncates long names', () => {