mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-31 16:46:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22de4b14ee | |||
| 404e3466d9 | |||
| 9df021eb8f | |||
| 98d0dba6d4 | |||
| 5ec2371879 | |||
| e1ac5e0fc8 |
@@ -74,6 +74,11 @@ class AuthorizationError(Exception):
|
||||
error_code = 'forbidden'
|
||||
|
||||
|
||||
class AuthenticationDeniedError(AuthorizationError):
|
||||
status_code = 401
|
||||
error_code = 'invalid_authentication'
|
||||
|
||||
|
||||
class WorkspaceRequiredError(AuthorizationError):
|
||||
status_code = 400
|
||||
error_code = 'workspace_required'
|
||||
|
||||
@@ -9,6 +9,7 @@ class PrincipalType(enum.StrEnum):
|
||||
|
||||
ACCOUNT = 'account'
|
||||
API_KEY = 'api_key'
|
||||
SUPPORT_ADMIN = 'support_admin'
|
||||
SYSTEM = 'system'
|
||||
PUBLIC_BOT = 'public_bot'
|
||||
|
||||
@@ -19,7 +20,9 @@ class PrincipalContext:
|
||||
|
||||
principal_type: PrincipalType
|
||||
account_uuid: str | None = None
|
||||
actor_account_uuid: str | None = None
|
||||
api_key_uuid: str | None = None
|
||||
support_session_id: str | None = None
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -15,8 +15,17 @@ from ....workspace.collaboration import MembershipPermissionError, WorkspaceColl
|
||||
from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ....core.errors import TaskCapacityError
|
||||
from ..authz import AuthorizationError, Permission, permissions_for_role, require_permission
|
||||
from ..authz import (
|
||||
AuthenticationDeniedError,
|
||||
AuthorizationError,
|
||||
Permission,
|
||||
PermissionDeniedError,
|
||||
WorkspaceRequiredError,
|
||||
permissions_for_role,
|
||||
require_permission,
|
||||
)
|
||||
from ..context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
from ....cloud.support_admin import SupportAdminSessionError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
@@ -51,6 +60,17 @@ class AuthType(enum.Enum):
|
||||
USER_TOKEN_OR_API_KEY = 'user-token-or-api-key'
|
||||
|
||||
|
||||
_SUPPORT_ADMIN_DENIED_PERMISSIONS = frozenset(
|
||||
{
|
||||
Permission.OWNER_TRANSFER.value,
|
||||
Permission.MEMBER_VIEW.value,
|
||||
Permission.MEMBER_INVITE.value,
|
||||
Permission.MEMBER_UPDATE_ROLE.value,
|
||||
Permission.MEMBER_REMOVE.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RouterGroup(abc.ABC):
|
||||
name: str
|
||||
|
||||
@@ -95,6 +115,10 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
if self._is_support_admin_token(token):
|
||||
raise AuthenticationDeniedError(
|
||||
'Support admin tokens cannot be refreshed or used on account endpoints'
|
||||
)
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
# Account-token routes deliberately stop before Workspace
|
||||
# selection. They may bootstrap a selector, but cannot
|
||||
@@ -111,8 +135,13 @@ class RouterGroup(abc.ABC):
|
||||
return self.http_status(401, -1, 'No valid user token provided')
|
||||
|
||||
try:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
||||
if request_context is not None:
|
||||
self._require_support_admin_route_allowed(rule, f, permission)
|
||||
user_email = None
|
||||
else:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
if permission is not None:
|
||||
if request_context is None:
|
||||
raise AuthorizationError('Workspace authorization is unavailable')
|
||||
@@ -141,10 +170,20 @@ class RouterGroup(abc.ABC):
|
||||
return self._auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if token and self._is_support_admin_token(token):
|
||||
try:
|
||||
request_context = await self._authenticate_support_admin(token, auth_type)
|
||||
if request_context is None:
|
||||
raise AuthenticationDeniedError('Invalid support admin token')
|
||||
self._require_support_admin_route_allowed(rule, f, permission)
|
||||
if permission is not None:
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
# Try API key first (check X-API-Key header)
|
||||
api_key = quart.request.headers.get('X-API-Key', '')
|
||||
|
||||
if api_key:
|
||||
elif api_key := quart.request.headers.get('X-API-Key', ''):
|
||||
# API key authentication
|
||||
try:
|
||||
request_context = await self._authenticate_api_key(api_key, auth_type)
|
||||
@@ -155,8 +194,6 @@ class RouterGroup(abc.ABC):
|
||||
return self._auth_error_response(e)
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
if not token:
|
||||
return self.http_status(
|
||||
401, -1, 'No valid authentication provided (user token or API key required)'
|
||||
@@ -164,7 +201,7 @@ class RouterGroup(abc.ABC):
|
||||
|
||||
try:
|
||||
account, user_email = await self._authenticate_account(token)
|
||||
request_context = await self._resolve_account_context(account, auth_type, token=token)
|
||||
request_context = await self._resolve_account_context(account, auth_type)
|
||||
if permission is not None:
|
||||
if request_context is None:
|
||||
raise AuthorizationError('Workspace authorization is unavailable')
|
||||
@@ -268,6 +305,77 @@ class RouterGroup(abc.ABC):
|
||||
raise ValueError('User not found')
|
||||
return account, account.user
|
||||
|
||||
def _is_support_admin_token(self, token: str) -> bool:
|
||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
detector = getattr(service, 'is_support_admin_token', None)
|
||||
return callable(detector) and detector(token) is True
|
||||
|
||||
async def _authenticate_support_admin(
|
||||
self,
|
||||
token: str,
|
||||
auth_type: AuthType,
|
||||
*,
|
||||
workspace_uuid: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> RequestContext | None:
|
||||
service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
detector = getattr(service, 'is_support_admin_token', None)
|
||||
if service is None or not callable(detector) or detector(token) is not True:
|
||||
return None
|
||||
|
||||
requested_workspace_uuid = (
|
||||
workspace_uuid if workspace_uuid is not None else quart.request.headers.get('X-Workspace-Id')
|
||||
)
|
||||
if not requested_workspace_uuid:
|
||||
raise WorkspaceRequiredError('Support admin token requires an explicit Workspace selector')
|
||||
try:
|
||||
identity = await service.authenticate_token(
|
||||
token,
|
||||
requested_workspace_uuid=requested_workspace_uuid,
|
||||
)
|
||||
except SupportAdminSessionError as exc:
|
||||
raise AuthenticationDeniedError(str(exc)) from exc
|
||||
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
identity.instance_uuid,
|
||||
identity.workspace_uuid,
|
||||
)
|
||||
request_context = RequestContext(
|
||||
instance_uuid=identity.instance_uuid,
|
||||
placement_generation=identity.placement_generation,
|
||||
request_id=request_id or self.request_id(),
|
||||
auth_type=auth_type.value,
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.SUPPORT_ADMIN,
|
||||
actor_account_uuid=identity.actor_account_uuid,
|
||||
support_session_id=identity.grant_jti_hash,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid=identity.workspace_uuid,
|
||||
membership_uuid=None,
|
||||
role='owner',
|
||||
permissions=permissions_for_role('owner') - _SUPPORT_ADMIN_DENIED_PERMISSIONS,
|
||||
membership_revision=0,
|
||||
),
|
||||
entitlement_revision=entitlement_revision,
|
||||
)
|
||||
quart.g.request_context = request_context
|
||||
quart.g.workspace_membership = None
|
||||
return request_context
|
||||
|
||||
@staticmethod
|
||||
def _require_support_admin_route_allowed(
|
||||
rule: str,
|
||||
handler: RouteCallable,
|
||||
permission: Permission | str | None,
|
||||
) -> None:
|
||||
parameters = inspect.signature(handler).parameters
|
||||
if rule.startswith('/api/v1/user/') or 'account' in parameters or 'user_email' in parameters:
|
||||
raise AuthenticationDeniedError('Support admin tokens are not permitted on account endpoints')
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
if permission_value in _SUPPORT_ADMIN_DENIED_PERMISSIONS:
|
||||
raise PermissionDeniedError(permission_value)
|
||||
|
||||
async def _resolve_account_context(
|
||||
self,
|
||||
account: typing.Any,
|
||||
@@ -282,20 +390,6 @@ class RouterGroup(abc.ABC):
|
||||
return None
|
||||
|
||||
requested_workspace_uuid = quart.request.headers.get('X-Workspace-Id')
|
||||
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
|
||||
admin_owner_scope = typing.cast(
|
||||
dict[str, str] | None,
|
||||
scope_resolver(token) if token and callable(scope_resolver) else None,
|
||||
)
|
||||
if admin_owner_scope is not None:
|
||||
scoped_workspace_uuid = admin_owner_scope['workspace_uuid']
|
||||
if requested_workspace_uuid and requested_workspace_uuid != scoped_workspace_uuid:
|
||||
self.ap.logger.warning(
|
||||
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
|
||||
admin_owner_scope['actor_account_uuid'], scoped_workspace_uuid, requested_workspace_uuid,
|
||||
)
|
||||
raise MembershipPermissionError('Admin owner session is scoped to another Workspace')
|
||||
requested_workspace_uuid = scoped_workspace_uuid
|
||||
access = await collaboration_service.resolve_account_workspace(account_uuid, requested_workspace_uuid)
|
||||
entitlement_revision = await self._resolve_entitlement_revision(
|
||||
access.execution.instance_uuid,
|
||||
|
||||
@@ -97,18 +97,17 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
if not token or not workspace_uuid:
|
||||
raise ValueError('Authentication is required')
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
scope_resolver = getattr(self.ap.user_service, 'get_admin_owner_scope', None)
|
||||
admin_owner_scope = typing.cast(
|
||||
dict[str, str] | None,
|
||||
scope_resolver(token) if callable(scope_resolver) else None,
|
||||
support_context = await self._authenticate_support_admin(
|
||||
token,
|
||||
group.AuthType.USER_TOKEN,
|
||||
workspace_uuid=workspace_uuid,
|
||||
request_id=quart.websocket.headers.get('X-Request-Id') or str(uuid.uuid4()),
|
||||
)
|
||||
if admin_owner_scope is not None and workspace_uuid != admin_owner_scope['workspace_uuid']:
|
||||
self.ap.logger.warning(
|
||||
'cloud_admin_owner_scope_rejected actor_account_uuid=%s target_workspace_uuid=%s requested_workspace_uuid=%s',
|
||||
admin_owner_scope['actor_account_uuid'], admin_owner_scope['workspace_uuid'], workspace_uuid,
|
||||
)
|
||||
raise ValueError('Admin owner session is scoped to another Workspace')
|
||||
if support_context is not None:
|
||||
require_permission(support_context, Permission.RUNTIME_OPERATE)
|
||||
return support_context, token
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
collaboration_service = getattr(self.ap, 'workspace_collaboration_service', None)
|
||||
if not isinstance(account_uuid, str) or collaboration_service is None:
|
||||
@@ -142,6 +141,23 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
) -> RequestContext:
|
||||
"""Recheck revocable account, membership, permission, and placement state."""
|
||||
|
||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
||||
current_context = await self._authenticate_support_admin(
|
||||
token,
|
||||
group.AuthType.USER_TOKEN,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
request_id=request_context.request_id,
|
||||
)
|
||||
if current_context is None or current_context.principal != request_context.principal:
|
||||
raise ValueError('WebSocket support admin session changed')
|
||||
if (
|
||||
current_context.instance_uuid != request_context.instance_uuid
|
||||
or current_context.placement_generation != request_context.placement_generation
|
||||
):
|
||||
raise ValueError('WebSocket authorization changed')
|
||||
require_permission(current_context, Permission.RUNTIME_OPERATE)
|
||||
return current_context
|
||||
|
||||
account, _ = await self._authenticate_account(token)
|
||||
account_uuid = getattr(account, 'uuid', None)
|
||||
if account_uuid != request_context.account_uuid:
|
||||
@@ -222,6 +238,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
scope=WebSocketScope.from_context(request_context),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
trigger_principal=request_context.principal,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
send_queue_size=(
|
||||
self.ap.instance_config.data.get('system', {})
|
||||
@@ -402,7 +419,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
)
|
||||
elif message_type == 'message':
|
||||
try:
|
||||
await self._revalidate_websocket_authorization(request_context, token)
|
||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
break
|
||||
|
||||
@@ -22,6 +22,7 @@ class _AdapterSessionScope:
|
||||
principal_type: str
|
||||
account_uuid: str | None
|
||||
api_key_uuid: str | None
|
||||
support_session_id: str | None
|
||||
|
||||
@classmethod
|
||||
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
||||
@@ -33,6 +34,7 @@ class _AdapterSessionScope:
|
||||
principal_type=principal.principal_type.value,
|
||||
account_uuid=principal.account_uuid,
|
||||
api_key_uuid=principal.api_key_uuid,
|
||||
support_session_id=principal.support_session_id,
|
||||
)
|
||||
|
||||
def matches(self, request_context: RequestContext) -> bool:
|
||||
|
||||
@@ -318,6 +318,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return await operation()
|
||||
|
||||
async def _require_authenticated_plugin_runtime_context(
|
||||
self,
|
||||
request_context: RequestContext,
|
||||
) -> ExecutionContext:
|
||||
"""Fence an authenticated resource request to its injected Workspace."""
|
||||
return await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
|
||||
async def _require_public_plugin_runtime_context(self) -> ExecutionContext:
|
||||
"""Resolve public assets only for the OSS singleton Workspace.
|
||||
|
||||
@@ -372,7 +379,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
|
||||
return self.success(data={'plugins': redact_plugin_secrets(plugins)})
|
||||
@@ -385,7 +392,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get plugin debug information including debug URL and key"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
debug_info = await self.ap.plugin_connector.get_debug_info()
|
||||
|
||||
# Get debug URL from config
|
||||
@@ -428,7 +435,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -469,7 +476,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -489,7 +496,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
plugin = await self.ap.plugin_connector.get_plugin_info(author, plugin_name)
|
||||
if plugin is None:
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
@@ -506,7 +513,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
await self.ap.plugin_connector.set_plugin_config(author, plugin_name, config)
|
||||
return self.success(data={})
|
||||
|
||||
@@ -517,7 +524,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
language = quart.request.args.get('language', 'en')
|
||||
readme = await self.ap.plugin_connector.get_plugin_readme(author, plugin_name, language=language)
|
||||
return self.success(data={'readme': readme})
|
||||
@@ -529,7 +536,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.AUDIT_VIEW,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> quart.Response:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
try:
|
||||
limit = int(quart.request.args.get('limit', 200))
|
||||
except (TypeError, ValueError):
|
||||
@@ -538,6 +545,44 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
logs = await self.ap.plugin_connector.get_plugin_logs(author, plugin_name, limit=limit, level=level)
|
||||
return self.success(data={'logs': logs})
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/authenticated-icon',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(
|
||||
author: str,
|
||||
plugin_name: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
icon_data = await self.ap.plugin_connector.get_plugin_icon(author, plugin_name)
|
||||
icon_bytes = await asyncio.to_thread(base64.b64decode, icon_data['plugin_icon_base64'])
|
||||
return quart.Response(icon_bytes, mimetype=icon_data['mime_type'])
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/authenticated-assets/<path:filepath>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(
|
||||
author: str,
|
||||
plugin_name: str,
|
||||
filepath: str,
|
||||
request_context: RequestContext,
|
||||
) -> quart.Response:
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
asset_path = _normalize_plugin_asset_path(filepath)
|
||||
if asset_path is None:
|
||||
return quart.Response('Asset not found', status=404)
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||
if not asset_data.get('asset_base64'):
|
||||
return quart.Response('Asset not found', status=404)
|
||||
asset_bytes = await asyncio.to_thread(base64.b64decode, asset_data['asset_base64'])
|
||||
return quart.Response(asset_bytes, mimetype=asset_data['mime_type'])
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/icon',
|
||||
methods=['GET'],
|
||||
@@ -596,7 +641,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(author: str, plugin_name: str, request_context: RequestContext) -> str:
|
||||
"""Forward a page API request to the plugin."""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
if not isinstance(data, dict):
|
||||
return self.http_status(400, -1, 'invalid request body')
|
||||
@@ -625,7 +670,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get releases from a GitHub repository URL"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
repo_url = data.get('repo_url', '')
|
||||
|
||||
@@ -705,7 +750,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Get assets from a specific GitHub release"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
data = await quart.request.json
|
||||
owner = data.get('owner', '')
|
||||
repo = data.get('repo', '')
|
||||
@@ -901,7 +946,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -942,7 +987,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Upload a file for plugin configuration"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
file = (await quart.request.files).get('file')
|
||||
if file is None:
|
||||
return self.http_status(400, -1, 'file is required')
|
||||
@@ -974,7 +1019,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def _(file_key: str, request_context: RequestContext) -> str:
|
||||
"""Delete a plugin configuration file"""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
await self._require_authenticated_plugin_runtime_context(request_context)
|
||||
if not self.ap.storage_mgr.is_scoped_object_key(file_key, expected_owner_type='plugin_config'):
|
||||
return self.http_status(400, -1, 'invalid file key')
|
||||
|
||||
|
||||
@@ -396,6 +396,19 @@ class UserRouterGroup(group.RouterGroup):
|
||||
launch_assertion,
|
||||
expected_workspace_uuid=workspace_uuid,
|
||||
)
|
||||
if launch.get('launch_mode') == 'support_admin':
|
||||
token = launch.get('support_admin_token')
|
||||
if not token:
|
||||
raise SpaceLaunchError('Support admin launch session was not issued')
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
'workspace_uuid': launch['workspace_uuid'],
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': launch['actor_account_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')
|
||||
@@ -404,19 +417,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
account.uuid,
|
||||
launch['workspace_uuid'],
|
||||
)
|
||||
admin_owner_scope = None
|
||||
if launch.get('launch_mode') == 'admin_owner':
|
||||
if access.membership.role != 'owner':
|
||||
raise SpaceLaunchError('Admin launch principal is not an active Workspace owner')
|
||||
admin_owner_scope = {
|
||||
'actor_account_uuid': launch['actor_account_uuid'],
|
||||
'workspace_uuid': launch['workspace_uuid'],
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
token = await self.ap.user_service.generate_jwt_token(
|
||||
account,
|
||||
admin_owner_scope=admin_owner_scope,
|
||||
)
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
return self.success(
|
||||
data={
|
||||
'token': token,
|
||||
|
||||
@@ -5,7 +5,7 @@ import typing
|
||||
import quart
|
||||
|
||||
from ...authz import Permission, permissions_for_role
|
||||
from ...context import RequestContext
|
||||
from ...context import PrincipalType, RequestContext
|
||||
from ...service.user import AccountExistsLoginRequiredError, ControlPlaneDirectoryRequiredError
|
||||
from .....entity.persistence.workspace import Workspace, WorkspaceInvitation, WorkspaceMembership
|
||||
from .....entity.persistence.workspace import WorkspaceSource
|
||||
@@ -120,9 +120,6 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
|
||||
async def _(request_context: RequestContext) -> typing.Any:
|
||||
membership = quart.g.workspace_membership
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
||||
plan_name: str | None = None
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
@@ -132,6 +129,28 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
minimum_revision=request_context.entitlement_revision,
|
||||
)
|
||||
plan_name = entitlement.plan_name
|
||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
||||
return self.success(
|
||||
data={
|
||||
'workspace': _workspace_payload(workspace),
|
||||
'membership': {
|
||||
'uuid': None,
|
||||
'workspace_uuid': request_context.workspace_uuid,
|
||||
'account_uuid': None,
|
||||
'email': None,
|
||||
'role': 'owner',
|
||||
'status': 'active',
|
||||
'joined_at': None,
|
||||
'created_at': None,
|
||||
},
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
}
|
||||
)
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
if account is None:
|
||||
return self.http_status(401, 'invalid_authentication', 'Account not found')
|
||||
return self.success(
|
||||
data={
|
||||
'workspace': _workspace_payload(workspace),
|
||||
|
||||
@@ -403,18 +403,9 @@ class UserService:
|
||||
async def generate_jwt_token(
|
||||
self,
|
||||
account: user.User | str,
|
||||
*,
|
||||
admin_owner_scope: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
jwt_expire = self.ap.instance_config.data['system']['jwt']['expire']
|
||||
if admin_owner_scope is not None:
|
||||
expected = {'actor_account_uuid', 'workspace_uuid', 'effective_role'}
|
||||
if set(admin_owner_scope) != expected or admin_owner_scope.get('effective_role') != 'owner':
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
if not admin_owner_scope.get('actor_account_uuid') or not admin_owner_scope.get('workspace_uuid'):
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
jwt_expire = min(int(jwt_expire), 300)
|
||||
|
||||
account_obj: user.User | None = account if not isinstance(account, str) and hasattr(account, 'user') else None
|
||||
user_email = account_obj.user if account_obj is not None else account
|
||||
@@ -439,39 +430,9 @@ class UserService:
|
||||
'account_revision': account_obj.projection_revision,
|
||||
}
|
||||
)
|
||||
if admin_owner_scope is not None:
|
||||
payload['admin_owner_scope'] = dict(admin_owner_scope)
|
||||
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||
|
||||
def get_admin_owner_scope(self, token: str) -> dict[str, str] | None:
|
||||
# Authentication already ran. Decode only to detect whether this optional
|
||||
# claim exists, preserving bounded legacy tokens that omit iss/aud.
|
||||
unverified = jwt.decode(token, options={'verify_signature': False})
|
||||
if unverified.get('admin_owner_scope') is None:
|
||||
return None
|
||||
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
issuer, audience = self._jwt_identity()
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
options={'require': ['exp', 'iss', 'aud']},
|
||||
)
|
||||
scope = payload.get('admin_owner_scope')
|
||||
if not isinstance(scope, dict) or set(scope) != {'actor_account_uuid', 'workspace_uuid', 'effective_role'}:
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
if scope.get('effective_role') != 'owner':
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
actor = scope.get('actor_account_uuid')
|
||||
workspace = scope.get('workspace_uuid')
|
||||
if not isinstance(actor, str) or not actor or not isinstance(workspace, str) or not workspace:
|
||||
raise ValueError('Invalid admin owner token scope')
|
||||
return {'actor_account_uuid': actor, 'workspace_uuid': workspace, 'effective_role': 'owner'}
|
||||
|
||||
async def verify_jwt_token(self, token: str) -> str:
|
||||
account = await self.get_authenticated_account(token, allow_unresolved_legacy=True)
|
||||
if isinstance(account, str):
|
||||
|
||||
@@ -15,12 +15,15 @@ from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .support_admin import SupportAdminReplayError, SupportAdminSessionError, hash_grant_jti
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
CONTROL_PLANE_TYP = 'langbot-control-plane+jwt'
|
||||
LAUNCH_KIND = 'workspace.launch'
|
||||
SUPPORT_ADMIN_LAUNCH_KIND = 'workspace.support_admin_launch'
|
||||
EXPECTED_ISSUER = 'langbot-space'
|
||||
EXPECTED_AUDIENCE = 'langbot-cloud-runtime'
|
||||
_CONSUMED_JTI_MAX_ENTRIES = 4096
|
||||
@@ -123,18 +126,15 @@ class SpaceLaunchService:
|
||||
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')
|
||||
kind = _required_string(claims, 'kind')
|
||||
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')
|
||||
launch_mode = payload.get('launch_mode')
|
||||
result = {
|
||||
'account_uuid': account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
}
|
||||
if launch_mode is not None:
|
||||
if launch_mode != 'admin_owner':
|
||||
raise SpaceLaunchError('Launch assertion mode is unsupported')
|
||||
if kind == SUPPORT_ADMIN_LAUNCH_KIND:
|
||||
if 'account_uuid' in payload:
|
||||
raise SpaceLaunchError('Admin launch assertion must not identify a customer Account')
|
||||
if payload.get('launch_mode') != 'support_admin' or payload.get('principal_type') != 'support_admin':
|
||||
raise SpaceLaunchError('Admin launch principal must be support_admin')
|
||||
actor_account_uuid = _required_string(payload, 'actor_account_uuid')
|
||||
if _required_string(payload, 'effective_role') != 'owner':
|
||||
raise SpaceLaunchError('Admin launch effective role must be owner')
|
||||
@@ -142,17 +142,43 @@ class SpaceLaunchService:
|
||||
expires_at = _required_int(claims, 'exp', minimum=1)
|
||||
if expires_at - issued_at > 90:
|
||||
raise SpaceLaunchError('Admin launch assertion lifetime exceeds 90 seconds')
|
||||
result.update({
|
||||
'launch_mode': 'admin_owner',
|
||||
grant_jti_hash = hash_grant_jti(_required_string(claims, 'jti'))
|
||||
result = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'launch_mode': 'support_admin',
|
||||
'actor_account_uuid': actor_account_uuid,
|
||||
'effective_role': 'owner',
|
||||
})
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
if launch_mode == 'admin_owner':
|
||||
'grant_jti_hash': grant_jti_hash,
|
||||
}
|
||||
support_service = getattr(self.ap, 'support_admin_session_service', None)
|
||||
if support_service is None or not callable(getattr(support_service, 'consume_launch_grant', None)):
|
||||
raise SpaceLaunchError('Durable support admin session service is unavailable')
|
||||
try:
|
||||
support_session = await support_service.consume_launch_grant(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
)
|
||||
except SupportAdminReplayError as exc:
|
||||
raise SpaceLaunchError('Launch assertion has already been consumed') from exc
|
||||
except SupportAdminSessionError as exc:
|
||||
raise SpaceLaunchError(str(exc)) from exc
|
||||
result['support_admin_token'] = support_session.token
|
||||
self.ap.logger.info(
|
||||
'cloud_admin_owner_launch_consumed actor_account_uuid=%s workspace_uuid=%s owner_account_uuid=%s',
|
||||
result['actor_account_uuid'], workspace_uuid, account_uuid,
|
||||
'cloud_support_admin_launch_consumed actor_account_uuid=%s workspace_uuid=%s',
|
||||
result['actor_account_uuid'],
|
||||
workspace_uuid,
|
||||
)
|
||||
return result
|
||||
|
||||
if payload.get('launch_mode') is not None:
|
||||
raise SpaceLaunchError('Launch assertion mode is unsupported')
|
||||
account_uuid = _required_string(payload, 'account_uuid')
|
||||
result = {
|
||||
'account_uuid': account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
}
|
||||
await self._consume_jti(_required_string(claims, 'jti'), _required_int(claims, 'exp', minimum=1))
|
||||
return result
|
||||
|
||||
def _verify_assertion(self, token: str) -> dict[str, typing.Any]:
|
||||
@@ -191,8 +217,9 @@ class SpaceLaunchService:
|
||||
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')
|
||||
kind = _required_string(claims, 'kind')
|
||||
if kind not in {LAUNCH_KIND, SUPPORT_ADMIN_LAUNCH_KIND}:
|
||||
raise SpaceLaunchError('Launch assertion kind is not supported')
|
||||
|
||||
issued_at = _required_int(claims, 'iat')
|
||||
not_before = _required_int(claims, 'nbf')
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
|
||||
import jwt
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..entity.persistence.support_admin import SupportAdminTemporarySession
|
||||
from ..workspace.errors import WorkspaceError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ..core.app import Application
|
||||
|
||||
|
||||
SUPPORT_ADMIN_TOKEN_TYP = 'langbot-support-admin+jwt'
|
||||
SUPPORT_ADMIN_TOKEN_KIND = 'support_admin.session'
|
||||
SUPPORT_ADMIN_EFFECTIVE_ROLE = 'owner'
|
||||
SUPPORT_ADMIN_MAX_TOKEN_SECONDS = 300
|
||||
_SHA256_HEX = re.compile(r'^[0-9a-f]{64}$')
|
||||
|
||||
|
||||
class SupportAdminSessionError(ValueError):
|
||||
"""Raised when a support-admin session or token is not admissible."""
|
||||
|
||||
|
||||
class SupportAdminReplayError(SupportAdminSessionError):
|
||||
"""Raised when a launch grant JTI has already been consumed."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class IssuedSupportAdminSession:
|
||||
token: str
|
||||
grant_jti_hash: str
|
||||
workspace_uuid: str
|
||||
actor_account_uuid: str
|
||||
issued_at: datetime.datetime
|
||||
expires_at: datetime.datetime
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class SupportAdminSessionIdentity:
|
||||
grant_jti_hash: str
|
||||
workspace_uuid: str
|
||||
actor_account_uuid: str
|
||||
instance_uuid: str
|
||||
placement_generation: int
|
||||
|
||||
|
||||
def hash_grant_jti(jti: str) -> str:
|
||||
return hashlib.sha256(jti.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class SupportAdminSessionService:
|
||||
"""Issue and validate temporary Workspace-scoped support-admin sessions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: Application,
|
||||
*,
|
||||
wall_time: typing.Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.ap = ap
|
||||
self._wall_time = wall_time
|
||||
|
||||
async def consume_launch_grant(
|
||||
self,
|
||||
*,
|
||||
grant_jti_hash: str,
|
||||
workspace_uuid: str,
|
||||
actor_account_uuid: str,
|
||||
) -> IssuedSupportAdminSession:
|
||||
self._validate_grant_hash(grant_jti_hash)
|
||||
if not workspace_uuid or not actor_account_uuid:
|
||||
raise SupportAdminSessionError('Support admin session requires an actor and Workspace')
|
||||
|
||||
issued_at = self._utcnow()
|
||||
expires_at = issued_at + datetime.timedelta(seconds=SUPPORT_ADMIN_MAX_TOKEN_SECONDS)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if not callable(tenant_uow):
|
||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
||||
|
||||
try:
|
||||
async with tenant_uow(workspace_uuid) as uow:
|
||||
await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
||||
uow.session.add(
|
||||
SupportAdminTemporarySession(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
)
|
||||
await uow.session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise SupportAdminReplayError('Launch assertion has already been consumed') from exc
|
||||
except WorkspaceError as exc:
|
||||
raise SupportAdminSessionError('Workspace is unavailable for support access') from exc
|
||||
|
||||
return IssuedSupportAdminSession(
|
||||
token=self._encode_token(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
),
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
def is_support_admin_token(self, token: str) -> bool:
|
||||
"""Return True only for compact JWTs marked as support-admin tokens."""
|
||||
|
||||
if not isinstance(token, str) or token.count('.') != 2:
|
||||
return False
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
if header.get('typ') == SUPPORT_ADMIN_TOKEN_TYP:
|
||||
return True
|
||||
try:
|
||||
payload = jwt.decode(token, options={'verify_signature': False})
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
return payload.get('kind') == SUPPORT_ADMIN_TOKEN_KIND
|
||||
|
||||
async def authenticate_token(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
requested_workspace_uuid: str | None,
|
||||
) -> SupportAdminSessionIdentity:
|
||||
if not self.is_support_admin_token(token):
|
||||
raise SupportAdminSessionError('Not a support admin token')
|
||||
workspace_uuid = (requested_workspace_uuid or '').strip()
|
||||
if not workspace_uuid:
|
||||
raise SupportAdminSessionError('Support admin token requires an explicit Workspace selector')
|
||||
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=['HS256'],
|
||||
issuer='langbot-core',
|
||||
audience=self._audience(workspace_uuid),
|
||||
options={'require': ['exp', 'iat', 'nbf', 'iss', 'aud']},
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise SupportAdminSessionError('Invalid support admin token') from exc
|
||||
self._validate_payload(payload, workspace_uuid)
|
||||
grant_jti_hash = payload['grant_jti_hash']
|
||||
actor_account_uuid = payload['actor_account_uuid']
|
||||
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if not callable(tenant_uow):
|
||||
raise SupportAdminSessionError('Support admin sessions require tenant persistence')
|
||||
|
||||
now = self._utcnow()
|
||||
async with tenant_uow(workspace_uuid) as uow:
|
||||
session = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
||||
if (
|
||||
session is None
|
||||
or session.workspace_uuid != workspace_uuid
|
||||
or session.actor_account_uuid != actor_account_uuid
|
||||
or session.revoked_at is not None
|
||||
or session.expires_at <= now
|
||||
):
|
||||
raise SupportAdminSessionError('Support admin session is inactive')
|
||||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid, session=uow.session)
|
||||
session.last_used_at = now
|
||||
await uow.session.flush()
|
||||
|
||||
return SupportAdminSessionIdentity(
|
||||
grant_jti_hash=grant_jti_hash,
|
||||
workspace_uuid=workspace_uuid,
|
||||
actor_account_uuid=actor_account_uuid,
|
||||
instance_uuid=binding.instance_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
|
||||
async def revoke_session(self, grant_jti_hash: str, workspace_uuid: str) -> None:
|
||||
self._validate_grant_hash(grant_jti_hash)
|
||||
now = self._utcnow()
|
||||
async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
||||
row = await uow.session.get(SupportAdminTemporarySession, grant_jti_hash)
|
||||
if row is not None and row.revoked_at is None:
|
||||
row.revoked_at = now
|
||||
|
||||
def _encode_token(
|
||||
self,
|
||||
*,
|
||||
grant_jti_hash: str,
|
||||
workspace_uuid: str,
|
||||
actor_account_uuid: str,
|
||||
issued_at: datetime.datetime,
|
||||
expires_at: datetime.datetime,
|
||||
) -> str:
|
||||
jwt_secret = self.ap.instance_config.data['system']['jwt']['secret']
|
||||
payload: dict[str, typing.Any] = {
|
||||
'kind': SUPPORT_ADMIN_TOKEN_KIND,
|
||||
'iss': 'langbot-core',
|
||||
'aud': self._audience(workspace_uuid),
|
||||
'sub': f'support-admin:{actor_account_uuid}',
|
||||
'iat': issued_at,
|
||||
'nbf': issued_at,
|
||||
'exp': expires_at,
|
||||
'actor_account_uuid': actor_account_uuid,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'effective_role': SUPPORT_ADMIN_EFFECTIVE_ROLE,
|
||||
'grant_jti_hash': grant_jti_hash,
|
||||
}
|
||||
return jwt.encode(payload, jwt_secret, algorithm='HS256', headers={'typ': SUPPORT_ADMIN_TOKEN_TYP})
|
||||
|
||||
def _validate_payload(self, payload: dict[str, typing.Any], workspace_uuid: str) -> None:
|
||||
if payload.get('kind') != SUPPORT_ADMIN_TOKEN_KIND:
|
||||
raise SupportAdminSessionError('Invalid support admin token kind')
|
||||
if payload.get('workspace_uuid') != workspace_uuid:
|
||||
raise SupportAdminSessionError('Support admin session is scoped to another Workspace')
|
||||
if payload.get('effective_role') != SUPPORT_ADMIN_EFFECTIVE_ROLE:
|
||||
raise SupportAdminSessionError('Invalid support admin token role')
|
||||
actor_account_uuid = payload.get('actor_account_uuid')
|
||||
if not isinstance(actor_account_uuid, str) or not actor_account_uuid.strip():
|
||||
raise SupportAdminSessionError('Invalid support admin actor')
|
||||
grant_jti_hash = payload.get('grant_jti_hash')
|
||||
if not isinstance(grant_jti_hash, str) or not _SHA256_HEX.match(grant_jti_hash):
|
||||
raise SupportAdminSessionError('Invalid support admin grant')
|
||||
|
||||
def _audience(self, workspace_uuid: str) -> str:
|
||||
return f'langbot-support-admin:{self.ap.workspace_service.instance_uuid}:{workspace_uuid}'
|
||||
|
||||
@staticmethod
|
||||
def _validate_grant_hash(grant_jti_hash: str) -> None:
|
||||
if not _SHA256_HEX.match(grant_jti_hash):
|
||||
raise SupportAdminSessionError('Invalid support admin grant')
|
||||
|
||||
def _utcnow(self) -> datetime.datetime:
|
||||
return datetime.datetime.fromtimestamp(self._wall_time(), datetime.UTC).replace(tzinfo=None)
|
||||
@@ -51,6 +51,7 @@ 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 support_admin as cloud_support_admin_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
|
||||
@@ -136,6 +137,8 @@ class Application:
|
||||
|
||||
space_launch_service: cloud_launch_module.SpaceLaunchService = None
|
||||
|
||||
support_admin_session_service: cloud_support_admin_module.SupportAdminSessionService = None
|
||||
|
||||
deployment: cloud_bootstrap_module.OpenSourceDeployment | cloud_bootstrap_module.VerifiedCloudDeployment = None
|
||||
|
||||
deployment_admission: cloud_bootstrap_module.DeploymentAdmissionGuard = None
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 import support_admin as cloud_support_admin_module
|
||||
from ...cloud.directory import directory_projection_limits_from_config
|
||||
from ...cloud.directory_projection import DirectoryProjectionService
|
||||
from ...cloud.entitlements import EntitlementResolver
|
||||
@@ -180,6 +181,7 @@ class BuildAppStage(stage.BootingStage):
|
||||
workspace_service_inst,
|
||||
)
|
||||
ap.invitation_delivery_service = invitation_delivery_module.InvitationDeliveryService(ap)
|
||||
ap.support_admin_session_service = cloud_support_admin_module.SupportAdminSessionService(ap)
|
||||
ap.space_launch_service = cloud_launch_module.SpaceLaunchService(ap)
|
||||
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
|
||||
@@ -186,9 +186,14 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
# At the final key
|
||||
if key in current:
|
||||
if isinstance(current[key], list):
|
||||
# Convert comma-separated string to list
|
||||
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
# Convert comma-separated values while preserving the
|
||||
# element type declared by a non-empty config default.
|
||||
items = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
if current[key]:
|
||||
exemplar = current[key][0]
|
||||
current[key] = [convert_value(item, exemplar) for item in items]
|
||||
else:
|
||||
current[key] = items
|
||||
elif isinstance(current[key], dict):
|
||||
# Skip dict types
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class SupportAdminTemporarySession(Base):
|
||||
"""Temporary support-admin Workspace access session."""
|
||||
|
||||
__tablename__ = 'support_admin_temporary_sessions'
|
||||
|
||||
grant_jti_hash = sqlalchemy.Column(sqlalchemy.String(64), primary_key=True)
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36),
|
||||
sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
)
|
||||
actor_account_uuid = sqlalchemy.Column(sqlalchemy.String(36), nullable=False)
|
||||
issued_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
revoked_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index(
|
||||
'ix_support_admin_sessions_workspace_expiry',
|
||||
'workspace_uuid',
|
||||
'expires_at',
|
||||
),
|
||||
sqlalchemy.CheckConstraint(
|
||||
'length(grant_jti_hash) = 64',
|
||||
name='ck_support_admin_sessions_grant_jti_hash',
|
||||
),
|
||||
)
|
||||
@@ -18,6 +18,17 @@ down_revision = '0008_mcp_resource_prefs'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
||||
|
||||
|
||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
||||
value = instance_id.strip()
|
||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
||||
try:
|
||||
return str(uuid.UUID(candidate))
|
||||
except ValueError:
|
||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
||||
|
||||
|
||||
def _table_names(conn: sa.Connection) -> set[str]:
|
||||
return set(sa.inspect(conn).get_table_names())
|
||||
@@ -403,7 +414,7 @@ def _bootstrap_default_workspace(conn: sa.Connection) -> None:
|
||||
.values(created_by_account_uuid=owner_account_uuid)
|
||||
)
|
||||
else:
|
||||
workspace_uuid = str(uuid.uuid4())
|
||||
workspace_uuid = _workspace_uuid_from_instance_id(instance_uuid)
|
||||
conn.execute(
|
||||
workspaces.insert().values(
|
||||
uuid=workspace_uuid,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""add temporary support-admin sessions
|
||||
|
||||
Revision ID: 0016_support_admin_sessions
|
||||
Revises: 0015_cloud_core_collab
|
||||
Create Date: 2026-07-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0016_support_admin_sessions'
|
||||
down_revision = '0015_cloud_core_collab'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_TABLE_NAME = 'support_admin_temporary_sessions'
|
||||
_POLICY_NAME = 'langbot_workspace_isolation'
|
||||
_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 upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
sa.Column('grant_jti_hash', sa.String(64), nullable=False),
|
||||
sa.Column(
|
||||
'workspace_uuid',
|
||||
sa.String(36),
|
||||
sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('actor_account_uuid', sa.String(36), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
'length(grant_jti_hash) = 64',
|
||||
name='ck_support_admin_sessions_grant_jti_hash',
|
||||
),
|
||||
sa.PrimaryKeyConstraint('grant_jti_hash'),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_support_admin_sessions_workspace_expiry',
|
||||
_TABLE_NAME,
|
||||
['workspace_uuid', 'expires_at'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return
|
||||
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
|
||||
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||
f'USING ({expression}) WITH CHECK ({expression})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == 'postgresql':
|
||||
table = _quote(conn, _TABLE_NAME)
|
||||
policy = _quote(conn, _POLICY_NAME)
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
|
||||
op.drop_index('ix_support_admin_sessions_workspace_expiry', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""align the OSS Workspace UUID with the persisted instance identity
|
||||
|
||||
Revision ID: 0017_oss_workspace_identity
|
||||
Revises: 0016_support_admin_sessions
|
||||
Create Date: 2026-07-31
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = '0017_oss_workspace_identity'
|
||||
down_revision = '0016_support_admin_sessions'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID('8ea04f29-8528-4cc3-bb28-30a838c89d76')
|
||||
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
|
||||
|
||||
|
||||
def _workspace_uuid_from_instance_id(instance_id: str) -> str:
|
||||
value = instance_id.strip()
|
||||
candidate = value[len('instance_') :] if value.startswith('instance_') else value
|
||||
try:
|
||||
return str(uuid.UUID(candidate))
|
||||
except ValueError:
|
||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
||||
|
||||
|
||||
def _quote(conn: sa.Connection, identifier: str) -> str:
|
||||
return conn.dialect.identifier_preparer.quote(identifier)
|
||||
|
||||
|
||||
def _defer_foreign_keys(conn: sa.Connection, inspector: sa.Inspector, table_names: list[str]) -> None:
|
||||
"""Allow the transaction to re-key a connected tenant graph atomically."""
|
||||
|
||||
if conn.dialect.name == 'sqlite':
|
||||
conn.execute(sa.text('PRAGMA defer_foreign_keys = ON'))
|
||||
return
|
||||
if conn.dialect.name != 'postgresql':
|
||||
raise RuntimeError(f'Unsupported Workspace identity migration dialect: {conn.dialect.name}')
|
||||
|
||||
for table_name in table_names:
|
||||
for foreign_key in inspector.get_foreign_keys(table_name):
|
||||
constraint_name = foreign_key.get('name')
|
||||
if not constraint_name:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
f'ALTER TABLE {_quote(conn, table_name)} '
|
||||
f'ALTER CONSTRAINT {_quote(conn, constraint_name)} DEFERRABLE INITIALLY DEFERRED'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _suspend_postgres_rls(
|
||||
conn: sa.Connection,
|
||||
table_names: list[str],
|
||||
) -> dict[str, tuple[bool, bool]]:
|
||||
if conn.dialect.name != 'postgresql':
|
||||
return {}
|
||||
|
||||
states: dict[str, tuple[bool, bool]] = {}
|
||||
for table_name in table_names:
|
||||
row = conn.execute(
|
||||
sa.text(
|
||||
'SELECT relrowsecurity, relforcerowsecurity '
|
||||
'FROM pg_class WHERE oid = to_regclass(:table_name)'
|
||||
),
|
||||
{'table_name': table_name},
|
||||
).one()
|
||||
enabled, forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
|
||||
states[table_name] = (enabled, forced)
|
||||
table = _quote(conn, table_name)
|
||||
if forced:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
|
||||
if enabled:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
|
||||
return states
|
||||
|
||||
|
||||
def _restore_postgres_rls(conn: sa.Connection, states: dict[str, tuple[bool, bool]]) -> None:
|
||||
for table_name, (enabled, forced) in states.items():
|
||||
table = _quote(conn, table_name)
|
||||
if enabled:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
|
||||
if forced:
|
||||
conn.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
table_names = inspector.get_table_names()
|
||||
if 'workspaces' not in table_names:
|
||||
return
|
||||
|
||||
metadata = sa.MetaData()
|
||||
workspaces = sa.Table('workspaces', metadata, autoload_with=conn)
|
||||
local_rows = conn.execute(sa.select(workspaces).where(workspaces.c.source == 'local')).mappings().all()
|
||||
if not local_rows:
|
||||
return
|
||||
if len(local_rows) != 1:
|
||||
raise RuntimeError('Cannot align OSS Workspace identity: expected exactly one local Workspace')
|
||||
|
||||
old_row = dict(local_rows[0])
|
||||
old_uuid = old_row['uuid']
|
||||
canonical_uuid = _workspace_uuid_from_instance_id(old_row['instance_uuid'])
|
||||
if old_uuid == canonical_uuid:
|
||||
return
|
||||
if conn.execute(sa.select(workspaces.c.uuid).where(workspaces.c.uuid == canonical_uuid)).scalar_one_or_none():
|
||||
raise RuntimeError(f'Cannot align OSS Workspace identity: target {canonical_uuid!r} already exists')
|
||||
|
||||
tenant_tables = [
|
||||
table_name
|
||||
for table_name in table_names
|
||||
if table_name == 'workspaces'
|
||||
or 'workspace_uuid' in {column['name'] for column in inspector.get_columns(table_name)}
|
||||
]
|
||||
rls_states = _suspend_postgres_rls(conn, tenant_tables)
|
||||
try:
|
||||
_defer_foreign_keys(conn, inspector, table_names)
|
||||
|
||||
# Release local source/slug uniqueness while the canonical parent exists
|
||||
# alongside the old parent for the duration of this transaction.
|
||||
temporary_slug = f'__workspace_rekey__{old_uuid}'
|
||||
conn.execute(
|
||||
workspaces.update()
|
||||
.where(workspaces.c.uuid == old_uuid)
|
||||
.values(source='cloud_projection', slug=temporary_slug)
|
||||
)
|
||||
new_row = dict(old_row)
|
||||
new_row['uuid'] = canonical_uuid
|
||||
conn.execute(workspaces.insert().values(**new_row))
|
||||
|
||||
for table_name in tenant_tables:
|
||||
if table_name == 'workspaces':
|
||||
continue
|
||||
table = sa.Table(table_name, metadata, autoload_with=conn, extend_existing=True)
|
||||
conn.execute(
|
||||
table.update()
|
||||
.where(table.c.workspace_uuid == old_uuid)
|
||||
.values(workspace_uuid=canonical_uuid)
|
||||
)
|
||||
|
||||
if 'metadata' in table_names:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
'UPDATE metadata SET value = :canonical_uuid '
|
||||
'WHERE key = :key AND value = :old_uuid'
|
||||
),
|
||||
{
|
||||
'canonical_uuid': canonical_uuid,
|
||||
'key': _OSS_WORKSPACE_METADATA_KEY,
|
||||
'old_uuid': old_uuid,
|
||||
},
|
||||
)
|
||||
conn.execute(workspaces.delete().where(workspaces.c.uuid == old_uuid))
|
||||
if conn.dialect.name == 'postgresql':
|
||||
# Fire deferred FK triggers before ALTER TABLE restores RLS; PostgreSQL
|
||||
# rejects ALTER TABLE while a relation has pending trigger events.
|
||||
conn.execute(sa.text('SET CONSTRAINTS ALL IMMEDIATE'))
|
||||
except Exception:
|
||||
# Alembic owns the transaction. Rollback restores the transactional RLS DDL.
|
||||
raise
|
||||
else:
|
||||
_restore_postgres_rls(conn, rls_states)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The previous random UUID is intentionally not recoverable. Keeping the
|
||||
# canonical identity preserves every FK and is safe for older application code.
|
||||
pass
|
||||
@@ -54,6 +54,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'workspace_memberships',
|
||||
'workspace_invitations',
|
||||
'workspace_execution_states',
|
||||
'support_admin_temporary_sessions',
|
||||
'workspace_metadata',
|
||||
'api_keys',
|
||||
'bots',
|
||||
|
||||
@@ -43,6 +43,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'workspace_memberships': 'workspace_uuid',
|
||||
'workspace_invitations': 'workspace_uuid',
|
||||
'workspace_execution_states': 'workspace_uuid',
|
||||
'support_admin_temporary_sessions': 'workspace_uuid',
|
||||
'workspace_metadata': 'workspace_uuid',
|
||||
'api_keys': 'workspace_uuid',
|
||||
'bots': 'workspace_uuid',
|
||||
|
||||
@@ -15,6 +15,7 @@ from ....provider import runner as runner_module
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ....utils import importutil, constants, runner as runner_utils
|
||||
from ....telemetry import features as telemetry_features
|
||||
from ....telemetry.identity import workspace_identity
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -265,7 +266,8 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
'duration_ms': duration_ms,
|
||||
'model_name': model_name,
|
||||
'version': constants.semantic_version,
|
||||
'instance_id': constants.instance_id,
|
||||
**workspace_identity(get_query_execution_context(query)),
|
||||
'runtime_instance_id': constants.instance_id,
|
||||
'edition': constants.edition,
|
||||
'pipeline_plugins': pipeline_plugins,
|
||||
'features': features,
|
||||
|
||||
@@ -179,10 +179,13 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
)
|
||||
file_format = 'image/jpeg'
|
||||
|
||||
# NOTE: Telegram's file.file_path is a full URL of the form
|
||||
# https://api.telegram.org/file/bot<TOKEN>/<path> which embeds the
|
||||
# bot token. Unlike the public CDN URLs used by other adapters, it
|
||||
# cannot be exposed safely, so only base64 is stored here.
|
||||
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
|
||||
message_components.append(
|
||||
platform_message.Image(
|
||||
url=file.file_path,
|
||||
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
from ...api.http.context import ExecutionContext
|
||||
from ...api.http.context import ExecutionContext, PrincipalContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SESSION_FILTER_UNSET = object()
|
||||
@@ -95,6 +95,9 @@ class WebSocketConnection(pydantic.BaseModel):
|
||||
metadata: dict = pydantic.Field(default_factory=dict)
|
||||
"""连接元数据(可存储额外信息)"""
|
||||
|
||||
trigger_principal: PrincipalContext | None = None
|
||||
"""Authenticated principal that opened this dashboard connection."""
|
||||
|
||||
@property
|
||||
def scope(self) -> WebSocketScope:
|
||||
return WebSocketScope(
|
||||
@@ -112,6 +115,7 @@ class WebSocketConnection(pydantic.BaseModel):
|
||||
workspace_uuid=self.workspace_uuid,
|
||||
placement_generation=self.placement_generation,
|
||||
pipeline_uuid=self.pipeline_uuid,
|
||||
trigger_principal=self.trigger_principal,
|
||||
)
|
||||
|
||||
|
||||
@@ -138,6 +142,7 @@ class WebSocketConnectionManager:
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
metadata: dict | None = None,
|
||||
trigger_principal: PrincipalContext | None = None,
|
||||
session_id: str | None = None,
|
||||
send_queue_size: int = _DEFAULT_SEND_QUEUE_SIZE,
|
||||
max_connections: int = 1024,
|
||||
@@ -174,6 +179,7 @@ class WebSocketConnectionManager:
|
||||
session_id=session_id,
|
||||
websocket=websocket,
|
||||
metadata=metadata or {},
|
||||
trigger_principal=trigger_principal,
|
||||
send_queue=asyncio.Queue(maxsize=send_queue_size),
|
||||
)
|
||||
|
||||
|
||||
@@ -27,6 +27,18 @@ if typing.TYPE_CHECKING:
|
||||
HEARTBEAT_INTERVAL_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
class WorkspaceResourceSnapshot(typing.TypedDict):
|
||||
workspace_uuid: str
|
||||
bot_count: int
|
||||
pipeline_count: int
|
||||
knowledge_base_count: int
|
||||
plugin_count: int
|
||||
mcp_server_count: int
|
||||
extension_count: int
|
||||
skill_count: int
|
||||
adapters: list[str]
|
||||
|
||||
|
||||
async def _count(
|
||||
ap: core_app.Application,
|
||||
table,
|
||||
@@ -52,8 +64,63 @@ async def _count(
|
||||
return -1
|
||||
|
||||
|
||||
async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||
"""Collect the anonymous instance profile snapshot."""
|
||||
async def _cloud_workspace_resource_counts(ap: core_app.Application, bindings) -> list[WorkspaceResourceSnapshot]:
|
||||
"""Summarize already-loaded Cloud registries without per-tenant SQL."""
|
||||
persistence_mgr = ap.persistence_mgr
|
||||
if getattr(getattr(persistence_mgr, 'mode', None), 'value', None) != 'cloud_runtime':
|
||||
return []
|
||||
|
||||
counts: dict[str, WorkspaceResourceSnapshot] = {
|
||||
binding.workspace_uuid: {
|
||||
'workspace_uuid': binding.workspace_uuid,
|
||||
'bot_count': 0,
|
||||
'pipeline_count': 0,
|
||||
'knowledge_base_count': 0,
|
||||
'plugin_count': 0,
|
||||
'mcp_server_count': 0,
|
||||
'extension_count': 0,
|
||||
'skill_count': 0,
|
||||
'adapters': [],
|
||||
}
|
||||
for binding in bindings
|
||||
}
|
||||
|
||||
adapter_sets: dict[str, set[str]] = {workspace_uuid: set() for workspace_uuid in counts}
|
||||
for key, bot in getattr(ap.platform_mgr, '_bots_by_key', {}).items():
|
||||
if len(key) >= 2 and key[1] in counts:
|
||||
counts[key[1]]['bot_count'] += 1
|
||||
adapter = getattr(bot, 'adapter', None)
|
||||
if adapter is not None and getattr(bot, 'enable', False):
|
||||
adapter_sets[key[1]].add(adapter.__class__.__name__)
|
||||
for key in getattr(ap.pipeline_mgr, '_pipelines_by_key', {}):
|
||||
if len(key) >= 2 and key[1] in counts:
|
||||
counts[key[1]]['pipeline_count'] += 1
|
||||
for key in getattr(ap.rag_mgr, 'knowledge_bases', {}):
|
||||
if len(key) >= 1 and key[0] in counts:
|
||||
counts[key[0]]['knowledge_base_count'] += 1
|
||||
for key in getattr(ap.tool_mgr.mcp_tool_loader, '_sessions', {}):
|
||||
if len(key) >= 2 and key[1] in counts:
|
||||
counts[key[1]]['mcp_server_count'] += 1
|
||||
for workspace_uuid, installations in getattr(ap.plugin_connector, '_workspace_installations', {}).items():
|
||||
if workspace_uuid in counts:
|
||||
counts[workspace_uuid]['plugin_count'] = len(installations)
|
||||
for key, skills in getattr(ap.skill_mgr, '_skills_by_scope', {}).items():
|
||||
if len(key) >= 2 and key[1] in counts:
|
||||
counts[key[1]]['skill_count'] += len(skills)
|
||||
|
||||
for workspace_uuid, resource in counts.items():
|
||||
resource['extension_count'] = resource['plugin_count'] + resource['mcp_server_count']
|
||||
resource['adapters'] = sorted(adapter_sets[workspace_uuid])
|
||||
return list(counts.values())
|
||||
|
||||
|
||||
async def build_heartbeat_payload(
|
||||
ap: core_app.Application,
|
||||
*,
|
||||
workspace_uuid: str,
|
||||
workspace_resource: WorkspaceResourceSnapshot | None = None,
|
||||
) -> dict:
|
||||
"""Collect one anonymous Workspace profile snapshot."""
|
||||
from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import mcp as persistence_mcp
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
@@ -136,11 +203,14 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if workspace_resource is not None:
|
||||
features.update({key: value for key, value in workspace_resource.items() if key != 'workspace_uuid'})
|
||||
|
||||
return {
|
||||
'event_type': 'instance_heartbeat',
|
||||
'query_id': '',
|
||||
'version': constants.semantic_version,
|
||||
'instance_id': constants.instance_id,
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'instance_create_ts': constants.instance_create_ts,
|
||||
'edition': constants.edition,
|
||||
'features': features,
|
||||
@@ -148,14 +218,34 @@ async def build_heartbeat_payload(ap: core_app.Application) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def build_heartbeat_payloads(ap: core_app.Application) -> list[dict]:
|
||||
"""Build one heartbeat per active Workspace."""
|
||||
bindings = await ap.workspace_service.list_active_execution_bindings()
|
||||
workspace_uuids = sorted({binding.workspace_uuid for binding in bindings})
|
||||
resources = {
|
||||
resource['workspace_uuid']: resource for resource in await _cloud_workspace_resource_counts(ap, bindings)
|
||||
}
|
||||
return [
|
||||
await build_heartbeat_payload(
|
||||
ap,
|
||||
workspace_uuid=workspace_uuid,
|
||||
workspace_resource=resources.get(workspace_uuid),
|
||||
)
|
||||
for workspace_uuid in workspace_uuids
|
||||
]
|
||||
|
||||
|
||||
async def heartbeat_loop(ap: core_app.Application) -> None:
|
||||
"""Send one heartbeat shortly after startup, then daily."""
|
||||
# Small delay so managers (platform, skills, plugins) finish loading first
|
||||
await asyncio.sleep(30)
|
||||
while True:
|
||||
try:
|
||||
payload = await build_heartbeat_payload(ap)
|
||||
await ap.telemetry.start_send_task(payload)
|
||||
for payload in await build_heartbeat_payloads(ap):
|
||||
# Heartbeats are a daily bounded batch, not best-effort query events.
|
||||
# Await each send so the TelemetryManager's 8-task queue cannot drop
|
||||
# Workspaces after the first batch.
|
||||
await ap.telemetry.send(payload)
|
||||
except Exception as e:
|
||||
try:
|
||||
ap.logger.debug(f'Telemetry heartbeat failed: {e}')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
|
||||
class WorkspaceExecutionContext(typing.Protocol):
|
||||
@property
|
||||
def workspace_uuid(self) -> str: ...
|
||||
|
||||
|
||||
def workspace_identity(execution_context: WorkspaceExecutionContext) -> dict[str, str]:
|
||||
"""Build the canonical telemetry identity for one Workspace execution."""
|
||||
workspace_uuid = execution_context.workspace_uuid.strip()
|
||||
if not workspace_uuid:
|
||||
raise ValueError("Telemetry execution Workspace UUID is empty")
|
||||
return {"workspace_uuid": workspace_uuid}
|
||||
@@ -2,7 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import typing
|
||||
|
||||
import httpx
|
||||
|
||||
from ..core import app as core_app
|
||||
from ..utils import httpclient
|
||||
|
||||
@@ -21,7 +25,7 @@ class TelemetryManager:
|
||||
def __init__(self, ap: core_app.Application):
|
||||
self.ap = ap
|
||||
|
||||
self.telemetry_config = {}
|
||||
self.telemetry_config: dict[str, typing.Any] = {}
|
||||
self.send_tasks: list[asyncio.Task] = []
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
@@ -131,7 +135,16 @@ class TelemetryManager:
|
||||
async with self._client_context() as client:
|
||||
try:
|
||||
# Use asyncio.wait_for to ensure we always bound the total time
|
||||
resp = await asyncio.wait_for(client.post(url, json=sanitized), timeout=10 + 1)
|
||||
telemetry_token = os.getenv('LANGBOT_TELEMETRY_INGEST_TOKEN', '').strip()
|
||||
if telemetry_token:
|
||||
request = client.post(
|
||||
url,
|
||||
json=sanitized,
|
||||
headers={'X-LangBot-Telemetry-Token': telemetry_token},
|
||||
)
|
||||
else:
|
||||
request = client.post(url, json=sanitized)
|
||||
resp = await asyncio.wait_for(request, timeout=10 + 1)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
body = await httpclient.response_text(resp, max_chars=200)
|
||||
@@ -143,7 +156,8 @@ class TelemetryManager:
|
||||
app_err = False
|
||||
try:
|
||||
j = await httpclient.parse_json_response(resp)
|
||||
if isinstance(j, dict) and j.get('code') is not None and int(j.get('code')) >= 400:
|
||||
app_code = j.get('code') if isinstance(j, dict) else None
|
||||
if app_code is not None and int(app_code) >= 400:
|
||||
app_err = True
|
||||
self.ap.logger.warning(
|
||||
f'Telemetry post to {url} returned application error code {j.get("code")} - {j.get("msg")}'
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
_INSTANCE_PREFIX = "instance_"
|
||||
_WORKSPACE_IDENTITY_NAMESPACE = uuid.UUID("8ea04f29-8528-4cc3-bb28-30a838c89d76")
|
||||
|
||||
|
||||
def workspace_uuid_from_instance_id(instance_id: str) -> str:
|
||||
"""Return the stable OSS Workspace UUID for a persisted instance identity."""
|
||||
value = instance_id.strip()
|
||||
if not value:
|
||||
raise ValueError("LangBot instance identity is empty")
|
||||
|
||||
candidate = value[len(_INSTANCE_PREFIX) :] if value.startswith(_INSTANCE_PREFIX) else value
|
||||
try:
|
||||
return str(uuid.UUID(candidate))
|
||||
except ValueError:
|
||||
return str(uuid.uuid5(_WORKSPACE_IDENTITY_NAMESPACE, value))
|
||||
@@ -30,6 +30,7 @@ from .errors import (
|
||||
WorkspaceOwnerAlreadyExistsError,
|
||||
)
|
||||
from .entities import WorkspaceExecutionBinding
|
||||
from .identity import workspace_uuid_from_instance_id
|
||||
from .policy import CloudWorkspacePolicy, SingleWorkspacePolicy
|
||||
from .repository import WorkspaceRepository
|
||||
|
||||
@@ -497,7 +498,7 @@ class WorkspaceService:
|
||||
created_by_account_uuid: str | None = None,
|
||||
) -> Workspace:
|
||||
return Workspace(
|
||||
uuid=str(uuid.uuid4()),
|
||||
uuid=workspace_uuid_from_instance_id(self.instance_uuid),
|
||||
instance_uuid=self.instance_uuid,
|
||||
name=name,
|
||||
slug=slug,
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from quart import Quart
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import PrincipalType, RequestContext
|
||||
from langbot.pkg.api.http.controller import group
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
|
||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
||||
from langbot.pkg.cloud.support_admin import SupportAdminSessionService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.support_admin import SupportAdminTemporarySession
|
||||
from langbot.pkg.entity.persistence.user import User
|
||||
from langbot.pkg.entity.persistence.workspace import (
|
||||
Workspace,
|
||||
WorkspaceExecutionState,
|
||||
WorkspaceMembership,
|
||||
)
|
||||
from langbot.pkg.workspace.service import WorkspaceService
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
|
||||
INSTANCE_UUID = 'instance-support-admin'
|
||||
WORKSPACE_UUID = '10000000-0000-4000-8000-000000000001'
|
||||
OTHER_WORKSPACE_UUID = '10000000-0000-4000-8000-000000000002'
|
||||
ACTOR_ACCOUNT_UUID = '20000000-0000-4000-8000-000000000001'
|
||||
KEY_ID = 'support-admin-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 _admin_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.support_admin_launch',
|
||||
'payload': {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'launch_mode': 'support_admin',
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': ACTOR_ACCOUNT_UUID,
|
||||
'effective_role': 'owner',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@group.group_class('support_admin_probe', '/api/v1/support-admin-probe')
|
||||
class SupportAdminProbeGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/user-token', auth_type=group.AuthType.USER_TOKEN, permission=Permission.WORKSPACE_VIEW)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(data=_context_payload(request_context))
|
||||
|
||||
@self.route(
|
||||
'/member-operation',
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.MEMBER_VIEW,
|
||||
)
|
||||
async def member_operation(request_context: RequestContext) -> str:
|
||||
return self.success(data=_context_payload(request_context))
|
||||
|
||||
@self.route(
|
||||
'/user-token-or-api-key',
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.WORKSPACE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
return self.success(data=_context_payload(request_context))
|
||||
|
||||
|
||||
def _context_payload(request_context: RequestContext) -> dict:
|
||||
return {
|
||||
'principal_type': request_context.principal.principal_type.value,
|
||||
'actor_account_uuid': request_context.principal.actor_account_uuid,
|
||||
'account_uuid': request_context.principal.account_uuid,
|
||||
'role': request_context.workspace.role,
|
||||
'membership_uuid': request_context.workspace.membership_uuid,
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
}
|
||||
|
||||
|
||||
class _TenantUow:
|
||||
def __init__(self, engine):
|
||||
self._engine = engine
|
||||
self.session = None
|
||||
self._transaction = None
|
||||
|
||||
async def __aenter__(self):
|
||||
session_factory = async_sessionmaker(self._engine, expire_on_commit=False)
|
||||
self.session = session_factory()
|
||||
self._transaction = await self.session.begin()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
try:
|
||||
if exc_type is None:
|
||||
await self._transaction.commit()
|
||||
else:
|
||||
await self._transaction.rollback()
|
||||
finally:
|
||||
await self.session.close()
|
||||
|
||||
|
||||
class _TenantScope:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
|
||||
class _PersistenceManager:
|
||||
def __init__(self, engine):
|
||||
self._engine = engine
|
||||
self.mode = SimpleNamespace(value='oss_compat')
|
||||
|
||||
def get_db_engine(self):
|
||||
return self._engine
|
||||
|
||||
def tenant_uow(self, workspace_uuid: str):
|
||||
del workspace_uuid
|
||||
return _TenantUow(self._engine)
|
||||
|
||||
def tenant_scope(self, workspace_uuid: str):
|
||||
del workspace_uuid
|
||||
return _TenantScope()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def support_admin_api(tmp_path):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "support-admin.db"}')
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(
|
||||
Base.metadata.create_all,
|
||||
tables=[
|
||||
User.__table__,
|
||||
Workspace.__table__,
|
||||
WorkspaceExecutionState.__table__,
|
||||
WorkspaceMembership.__table__,
|
||||
SupportAdminTemporarySession.__table__,
|
||||
],
|
||||
)
|
||||
for workspace_uuid, slug in (
|
||||
(WORKSPACE_UUID, 'support-admin-a'),
|
||||
(OTHER_WORKSPACE_UUID, 'support-admin-b'),
|
||||
):
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(Workspace).values(
|
||||
uuid=workspace_uuid,
|
||||
instance_uuid=INSTANCE_UUID,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
type='team',
|
||||
status='active',
|
||||
source='cloud_projection',
|
||||
projection_revision=1,
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(WorkspaceExecutionState).values(
|
||||
workspace_uuid=workspace_uuid,
|
||||
instance_uuid=INSTANCE_UUID,
|
||||
active_generation=1,
|
||||
state='active',
|
||||
write_fenced=False,
|
||||
source='cloud',
|
||||
desired_state_revision=1,
|
||||
)
|
||||
)
|
||||
|
||||
app = SimpleNamespace()
|
||||
app.persistence_mgr = _PersistenceManager(engine)
|
||||
app.instance_config = SimpleNamespace(
|
||||
data={
|
||||
'system': {
|
||||
'jwt': {'secret': 'support-admin-secret', 'expire': 3600},
|
||||
'websocket_retention': {},
|
||||
},
|
||||
'space': {
|
||||
'launch': {
|
||||
'control_plane_public_key': _base64url(public_key),
|
||||
}
|
||||
},
|
||||
'api': {'global_api_key': ''},
|
||||
}
|
||||
)
|
||||
app.logger = logging.getLogger('support-admin-test')
|
||||
app.deployment = SimpleNamespace(mode='cloud', multi_workspace_enabled=True, verification_key_id=KEY_ID)
|
||||
app.directory_projection_service = SimpleNamespace(require_ready=lambda: None)
|
||||
app.workspace_service = WorkspaceService(app, instance_uuid=INSTANCE_UUID)
|
||||
app.entitlement_resolver = SimpleNamespace(
|
||||
instance_uuid=INSTANCE_UUID,
|
||||
resolve=AsyncMock(return_value=SimpleNamespace(entitlement_revision=7)),
|
||||
)
|
||||
app.support_admin_session_service = SupportAdminSessionService(app)
|
||||
app.space_launch_service = SpaceLaunchService(app)
|
||||
app.user_service = SimpleNamespace()
|
||||
app.user_service.get_authenticated_account = AsyncMock(side_effect=AssertionError('normal account auth used'))
|
||||
app.user_service.verify_jwt_token = AsyncMock(side_effect=AssertionError('normal token verification used'))
|
||||
app.user_service.get_user_by_email = AsyncMock(side_effect=AssertionError('user lookup used'))
|
||||
app.apikey_service = SimpleNamespace()
|
||||
app.apikey_service.authenticate_api_key = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
instance_uuid=INSTANCE_UUID,
|
||||
workspace_uuid=OTHER_WORKSPACE_UUID,
|
||||
placement_generation=1,
|
||||
api_key_uuid='api-key',
|
||||
permissions=frozenset(permission.value for permission in Permission),
|
||||
)
|
||||
)
|
||||
|
||||
quart_app = Quart(__name__)
|
||||
await UserRouterGroup(app, quart_app).initialize()
|
||||
await SupportAdminProbeGroup(app, quart_app).initialize()
|
||||
|
||||
yield app, quart_app.test_client(), engine, private_key
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _issue_support_token(app, private_key: Ed25519PrivateKey, *, jti: str | None = None) -> dict[str, str]:
|
||||
launch = await app.space_launch_service.consume_assertion(
|
||||
_sign(private_key, _admin_claims(now=int(time.time()), jti=jti)),
|
||||
expected_workspace_uuid=WORKSPACE_UUID,
|
||||
)
|
||||
return launch
|
||||
|
||||
|
||||
def _auth(token: str, workspace_uuid: str = WORKSPACE_UUID) -> dict[str, str]:
|
||||
return {'Authorization': f'Bearer {token}', 'X-Workspace-Id': workspace_uuid}
|
||||
|
||||
|
||||
async def test_support_admin_membership_only_routes_are_denied(support_admin_api):
|
||||
app, client, _engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/support-admin-probe/member-operation',
|
||||
headers=_auth(launch['support_admin_token']),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (await response.get_json())['code'] == 'permission_denied'
|
||||
|
||||
|
||||
async def test_support_admin_check_token_is_rejected(support_admin_api):
|
||||
app, client, _engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
|
||||
response = await client.get('/api/v1/user/check-token', headers=_auth(launch['support_admin_token']))
|
||||
|
||||
assert response.status_code == 401
|
||||
assert (await response.get_json())['code'] == 'invalid_authentication'
|
||||
|
||||
|
||||
async def test_support_admin_cross_workspace_denied_for_user_token_and_or_api_key(support_admin_api):
|
||||
app, client, _engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
|
||||
missing_selector = await client.get(
|
||||
'/api/v1/support-admin-probe/user-token',
|
||||
headers={'Authorization': f'Bearer {launch["support_admin_token"]}'},
|
||||
)
|
||||
user_response = await client.get(
|
||||
'/api/v1/support-admin-probe/user-token',
|
||||
headers=_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID),
|
||||
)
|
||||
either_response = await client.get(
|
||||
'/api/v1/support-admin-probe/user-token-or-api-key',
|
||||
headers={
|
||||
**_auth(launch['support_admin_token'], OTHER_WORKSPACE_UUID),
|
||||
'X-API-Key': 'valid-api-key',
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_selector.status_code == 400
|
||||
assert user_response.status_code == 401
|
||||
assert either_response.status_code == 401
|
||||
app.apikey_service.authenticate_api_key.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_support_admin_request_context_has_actor_owner_and_no_membership(support_admin_api):
|
||||
app, client, engine, private_key = support_admin_api
|
||||
before_count = await _membership_count(engine)
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/support-admin-probe/user-token',
|
||||
headers=_auth(launch['support_admin_token']),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = (await response.get_json())['data']
|
||||
permissions = set(data.pop('permissions'))
|
||||
assert Permission.WORKSPACE_VIEW.value in permissions
|
||||
assert Permission.RESOURCE_MANAGE.value in permissions
|
||||
assert not permissions.intersection(
|
||||
{
|
||||
Permission.OWNER_TRANSFER.value,
|
||||
Permission.MEMBER_VIEW.value,
|
||||
Permission.MEMBER_INVITE.value,
|
||||
Permission.MEMBER_UPDATE_ROLE.value,
|
||||
Permission.MEMBER_REMOVE.value,
|
||||
}
|
||||
)
|
||||
assert data == {
|
||||
'principal_type': PrincipalType.SUPPORT_ADMIN.value,
|
||||
'actor_account_uuid': ACTOR_ACCOUNT_UUID,
|
||||
'account_uuid': None,
|
||||
'role': 'owner',
|
||||
'membership_uuid': None,
|
||||
}
|
||||
assert await _membership_count(engine) == before_count
|
||||
|
||||
|
||||
async def test_support_admin_missing_workspace_is_controlled_launch_failure(support_admin_api):
|
||||
app, _client, engine, private_key = support_admin_api
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sqlalchemy.delete(WorkspaceExecutionState).where(WorkspaceExecutionState.workspace_uuid == WORKSPACE_UUID)
|
||||
)
|
||||
|
||||
with pytest.raises(SpaceLaunchError, match='unavailable'):
|
||||
await _issue_support_token(app, private_key)
|
||||
|
||||
|
||||
async def test_support_admin_launch_replay_is_durable_across_service_instances(support_admin_api):
|
||||
app, _client, _engine, private_key = support_admin_api
|
||||
jti = str(uuid.uuid4())
|
||||
|
||||
await _issue_support_token(app, private_key, jti=jti)
|
||||
second_service = SpaceLaunchService(app)
|
||||
|
||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
||||
await second_service.consume_assertion(
|
||||
_sign(private_key, _admin_claims(now=int(time.time()), jti=jti)),
|
||||
expected_workspace_uuid=WORKSPACE_UUID,
|
||||
)
|
||||
|
||||
|
||||
async def test_support_admin_persisted_expiry_and_revocation_are_enforced(support_admin_api):
|
||||
app, client, engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
token = launch['support_admin_token']
|
||||
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sqlalchemy.update(SupportAdminTemporarySession)
|
||||
.where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash'])
|
||||
.values(expires_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(minutes=1))
|
||||
)
|
||||
expired = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(token))
|
||||
assert expired.status_code == 401
|
||||
|
||||
second = await _issue_support_token(app, private_key)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sqlalchemy.update(SupportAdminTemporarySession)
|
||||
.where(SupportAdminTemporarySession.grant_jti_hash == second['grant_jti_hash'])
|
||||
.values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None))
|
||||
)
|
||||
revoked = await client.get('/api/v1/support-admin-probe/user-token', headers=_auth(second['support_admin_token']))
|
||||
assert revoked.status_code == 401
|
||||
|
||||
|
||||
async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api):
|
||||
app, _client, _engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
captured_contexts = []
|
||||
|
||||
class Adapter:
|
||||
async def handle_websocket_message(self, connection, data):
|
||||
del data
|
||||
captured_contexts.append(connection.execution_context)
|
||||
await connection.send_queue.put({'type': 'handled'})
|
||||
connection.is_active = False
|
||||
|
||||
app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1')))
|
||||
app.platform_mgr = SimpleNamespace(
|
||||
get_websocket_proxy_bot=AsyncMock(return_value=SimpleNamespace(adapter=Adapter()))
|
||||
)
|
||||
|
||||
quart_app = Quart(__name__)
|
||||
await WebSocketChatRouterGroup(app, quart_app).initialize()
|
||||
|
||||
async with quart_app.test_client().websocket('/api/v1/pipelines/pipeline-1/ws/connect') as websocket:
|
||||
await websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
'type': 'authenticate',
|
||||
'token': launch['support_admin_token'],
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
}
|
||||
)
|
||||
)
|
||||
connected = json.loads(await websocket.receive())
|
||||
assert connected['type'] == 'connected'
|
||||
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
|
||||
handled = json.loads(await websocket.receive())
|
||||
assert handled['type'] == 'handled'
|
||||
|
||||
assert captured_contexts
|
||||
principal = captured_contexts[0].trigger_principal
|
||||
assert principal is not None
|
||||
assert principal.principal_type == PrincipalType.SUPPORT_ADMIN
|
||||
assert principal.actor_account_uuid == ACTOR_ACCOUNT_UUID
|
||||
|
||||
|
||||
async def _membership_count(engine) -> int:
|
||||
async with engine.connect() as connection:
|
||||
return int(
|
||||
await connection.scalar(
|
||||
sqlalchemy.select(sqlalchemy.func.count()).select_from(WorkspaceMembership),
|
||||
)
|
||||
or 0
|
||||
)
|
||||
@@ -270,7 +270,7 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space-credits',
|
||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-UUID': WORKSPACE_UUID},
|
||||
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -22,6 +22,7 @@ from langbot.pkg.persistence.alembic_runner import (
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.workspace.collaboration import normalize_email
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
@@ -109,6 +110,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
|
||||
assert workspace['instance_uuid'] == 'instance_migration_test'
|
||||
assert workspace['slug'] == 'default'
|
||||
assert workspace['status'] == 'active'
|
||||
@@ -149,6 +151,53 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_
|
||||
assert workspace_uuid_after == workspace_uuid_before
|
||||
|
||||
|
||||
async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
||||
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
|
||||
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
|
||||
schema = sa.MetaData()
|
||||
sa.Table(
|
||||
'metadata',
|
||||
schema,
|
||||
sa.Column('key', sa.String(255), primary_key=True),
|
||||
sa.Column('value', sa.String(255)),
|
||||
)
|
||||
sa.Table(
|
||||
'workspaces',
|
||||
schema,
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('slug', sa.String(255), nullable=False),
|
||||
sa.Column('source', sa.String(32), nullable=False),
|
||||
)
|
||||
sa.Table(
|
||||
'tenant_rows',
|
||||
schema,
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(schema.create_all)
|
||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
||||
{'uuid': old_workspace_uuid},
|
||||
)
|
||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
||||
try:
|
||||
@@ -362,6 +411,47 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
try:
|
||||
await _create_legacy_schema(engine)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
||||
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
|
||||
assert old_uuid
|
||||
assert instance_uuid
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||
async with engine.connect() as conn:
|
||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
||||
) == expected_uuid
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
||||
try:
|
||||
|
||||
@@ -419,62 +419,6 @@ class TestUserServiceGenerateJwtToken:
|
||||
|
||||
|
||||
|
||||
async def test_admin_owner_token_is_scoped_and_capped_at_five_minutes(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
||||
service = UserService(ap)
|
||||
before = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
||||
|
||||
token = await service.generate_jwt_token(
|
||||
'owner@example.com',
|
||||
admin_owner_scope={
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'owner',
|
||||
},
|
||||
)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
'test_secret',
|
||||
algorithms=['HS256'],
|
||||
options={'verify_aud': False},
|
||||
)
|
||||
|
||||
assert payload['exp'] <= before + 301
|
||||
assert service.get_admin_owner_scope(token) == {
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
|
||||
legacy_token = jwt.encode(
|
||||
{
|
||||
'user': 'legacy@example.com',
|
||||
'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=5),
|
||||
},
|
||||
'test_secret',
|
||||
algorithm='HS256',
|
||||
)
|
||||
assert service.get_admin_owner_scope(legacy_token) is None
|
||||
|
||||
async def test_admin_owner_token_rejects_invalid_scope(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.instance_config = SimpleNamespace()
|
||||
ap.instance_config.data = {'system': {'jwt': {'secret': 'test_secret', 'expire': 7200}}}
|
||||
service = UserService(ap)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid admin owner token scope'):
|
||||
await service.generate_jwt_token(
|
||||
'owner@example.com',
|
||||
admin_owner_scope={
|
||||
'actor_account_uuid': 'admin-uuid',
|
||||
'workspace_uuid': 'workspace-uuid',
|
||||
'effective_role': 'member',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestUserServiceVerifyJwtToken:
|
||||
"""Tests for verify_jwt_token method."""
|
||||
|
||||
|
||||
@@ -149,6 +149,36 @@ async def test_session_scope_matches_exact_tenant_placement_and_principal():
|
||||
assert sessions == {}
|
||||
|
||||
|
||||
async def test_support_admin_sessions_are_scoped_to_the_persisted_grant():
|
||||
def support_context(grant_jti_hash: str) -> RequestContext:
|
||||
return RequestContext(
|
||||
instance_uuid='instance-test',
|
||||
placement_generation=1,
|
||||
request_id='request-test',
|
||||
auth_type='support-admin',
|
||||
principal=PrincipalContext(
|
||||
principal_type=PrincipalType.SUPPORT_ADMIN,
|
||||
actor_account_uuid='support-actor',
|
||||
support_session_id=grant_jti_hash,
|
||||
),
|
||||
workspace=WorkspaceContext(
|
||||
workspace_uuid='workspace-a',
|
||||
membership_uuid=None,
|
||||
role='owner',
|
||||
permissions=frozenset({'resource.manage'}),
|
||||
),
|
||||
)
|
||||
|
||||
first_context = support_context('a' * 64)
|
||||
second_context = support_context('b' * 64)
|
||||
sessions: dict[str, dict] = {'session-test': {'status': 'waiting'}}
|
||||
_bind_session_scope(sessions['session-test'], first_context)
|
||||
|
||||
assert _get_owned_session(sessions, 'session-test', second_context) is None
|
||||
assert _pop_owned_session(sessions, 'session-test', second_context) is None
|
||||
assert _get_owned_session(sessions, 'session-test', first_context) is sessions['session-test']
|
||||
|
||||
|
||||
async def test_session_capacity_evicts_oldest_session_in_same_workspace():
|
||||
owner_context = _request_context()
|
||||
sessions: dict[str, dict] = {}
|
||||
|
||||
@@ -46,6 +46,20 @@ def plugin_router_cls():
|
||||
yield PluginsRouterGroup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticated_plugin_resource_fences_injected_workspace_context(plugin_router_cls):
|
||||
connector = SimpleNamespace(
|
||||
require_workspace_context=AsyncMock(return_value=CONTEXT),
|
||||
)
|
||||
router = object.__new__(plugin_router_cls)
|
||||
router.ap = SimpleNamespace(plugin_connector=connector)
|
||||
|
||||
result = await router._require_authenticated_plugin_runtime_context(CONTEXT)
|
||||
|
||||
assert result == CONTEXT
|
||||
connector.require_workspace_context.assert_awaited_once_with(CONTEXT)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_plugin_asset_route_is_disabled_for_multi_workspace_policy(plugin_router_cls):
|
||||
connector = SimpleNamespace(
|
||||
|
||||
@@ -11,6 +11,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from langbot.pkg.cloud.launch import SpaceLaunchError, SpaceLaunchService
|
||||
from langbot.pkg.cloud.support_admin import SupportAdminReplayError
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
@@ -57,10 +58,21 @@ def _service(private_key: Ed25519PrivateKey, *, now: int) -> SpaceLaunchService:
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
consumed: set[str] = set()
|
||||
|
||||
class DurableSupportAdminService:
|
||||
async def consume_launch_grant(self, **kwargs):
|
||||
grant_hash = kwargs['grant_jti_hash']
|
||||
if grant_hash in consumed:
|
||||
raise SupportAdminReplayError('already consumed')
|
||||
consumed.add(grant_hash)
|
||||
return SimpleNamespace(token='support-admin-token')
|
||||
|
||||
app = SimpleNamespace(
|
||||
deployment=SimpleNamespace(multi_workspace_enabled=True, verification_key_id=KEY_ID),
|
||||
workspace_service=SimpleNamespace(instance_uuid=INSTANCE_UUID),
|
||||
logger=SimpleNamespace(info=lambda *args, **kwargs: None),
|
||||
support_admin_session_service=DurableSupportAdminService(),
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'space': {
|
||||
@@ -94,50 +106,73 @@ async def test_consumes_admin_owner_launch_once_and_validates_claims():
|
||||
now = int(time.time())
|
||||
service = _service(private_key, now=now)
|
||||
claims = _claims(now=now)
|
||||
claims['kind'] = 'workspace.support_admin_launch'
|
||||
claims['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'launch_mode': 'support_admin',
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
)
|
||||
claims['payload'].pop('account_uuid')
|
||||
token = _sign(private_key, claims)
|
||||
|
||||
launch = await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
assert launch == {
|
||||
'account_uuid': ACCOUNT_UUID,
|
||||
'workspace_uuid': WORKSPACE_UUID,
|
||||
'launch_mode': 'admin_owner',
|
||||
'launch_mode': 'support_admin',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
'grant_jti_hash': launch['grant_jti_hash'],
|
||||
'support_admin_token': 'support-admin-token',
|
||||
}
|
||||
with pytest.raises(SpaceLaunchError, match='already been consumed'):
|
||||
await service.consume_assertion(token, expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
invalid = _claims(now=now)
|
||||
invalid['kind'] = 'workspace.support_admin_launch'
|
||||
invalid['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'launch_mode': 'support_admin',
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'member',
|
||||
}
|
||||
)
|
||||
invalid['payload'].pop('account_uuid')
|
||||
with pytest.raises(SpaceLaunchError, match='effective role'):
|
||||
await service.consume_assertion(_sign(private_key, invalid), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
too_long = _claims(now=now)
|
||||
too_long['kind'] = 'workspace.support_admin_launch'
|
||||
too_long['exp'] = now + 91
|
||||
too_long['payload'].update(
|
||||
{
|
||||
'launch_mode': 'admin_owner',
|
||||
'launch_mode': 'support_admin',
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
)
|
||||
too_long['payload'].pop('account_uuid')
|
||||
with pytest.raises(SpaceLaunchError, match='lifetime exceeds 90 seconds'):
|
||||
await service.consume_assertion(_sign(private_key, too_long), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
impersonating = _claims(now=now)
|
||||
impersonating['kind'] = 'workspace.support_admin_launch'
|
||||
impersonating['payload'].update(
|
||||
{
|
||||
'launch_mode': 'support_admin',
|
||||
'principal_type': 'support_admin',
|
||||
'actor_account_uuid': '33333333-3333-4333-8333-333333333333',
|
||||
'effective_role': 'owner',
|
||||
}
|
||||
)
|
||||
with pytest.raises(SpaceLaunchError, match='customer Account'):
|
||||
await service.consume_assertion(_sign(private_key, impersonating), expected_workspace_uuid=WORKSPACE_UUID)
|
||||
|
||||
|
||||
async def test_replay_cache_does_not_scan_all_live_assertions(monkeypatch):
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
|
||||
@@ -152,6 +152,19 @@ class TestApplyEnvOverridesToConfig:
|
||||
|
||||
assert result['system']['disabled_adapters'] == ['aiocqhttp', 'dingtalk', 'telegram']
|
||||
|
||||
def test_override_integer_list_preserves_item_type(self):
|
||||
"""Comma-separated overrides inherit the existing list item type."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
cfg = {'vdb': {'pgvector': {'allowed_dimensions': [384, 512]}}}
|
||||
env = {'VDB__PGVECTOR__ALLOWED_DIMENSIONS': '384,512,768'}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = load_config._apply_env_overrides_to_config(cfg)
|
||||
|
||||
assert result['vdb']['pgvector']['allowed_dimensions'] == [384, 512, 768]
|
||||
assert all(isinstance(item, int) for item in result['vdb']['pgvector']['allowed_dimensions'])
|
||||
|
||||
def test_override_list_value_empty_items(self):
|
||||
"""Test that empty items in comma-separated list are filtered."""
|
||||
load_config = get_load_config_module()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Tests for Telegram Dify form callback helpers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from telegram import ForceReply
|
||||
@@ -9,8 +10,10 @@ from telegram import ForceReply
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
from langbot.pkg.platform.sources.telegram import (
|
||||
TelegramAdapter,
|
||||
TelegramMessageConverter,
|
||||
_decode_telegram_base64_limited,
|
||||
_telegram_form_action_from_callback,
|
||||
_telegram_select_field_options,
|
||||
@@ -26,6 +29,70 @@ def test_telegram_base64_decode_is_bounded(monkeypatch):
|
||||
_decode_telegram_base64_limited('A' * 12)
|
||||
|
||||
|
||||
TELEGRAM_BOT_TOKEN = '123456789:AAExampleBotTokenThatMustNotLeak'
|
||||
TELEGRAM_FILE_URL = f'https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/photos/file_0.jpg'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_photo_does_not_expose_bot_token_in_image_url():
|
||||
"""Regression test for the Telegram bot-token leak.
|
||||
|
||||
telegram.Bot builds file.file_path as
|
||||
https://api.telegram.org/file/bot<TOKEN>/<path>, embedding the bot token.
|
||||
The converter must not copy that URL into Image.url, or the token leaks to
|
||||
the monitoring DB, dashboard and every installed plugin via the message
|
||||
chain. Only base64 (which carries no token) may be stored.
|
||||
"""
|
||||
tg_file = MagicMock()
|
||||
tg_file.file_path = TELEGRAM_FILE_URL
|
||||
|
||||
photo_size = MagicMock()
|
||||
photo_size.get_file = AsyncMock(return_value=tg_file)
|
||||
|
||||
message = MagicMock()
|
||||
message.text = None
|
||||
message.caption = None
|
||||
message.photo = [photo_size]
|
||||
message.voice = None
|
||||
message.document = None
|
||||
|
||||
response = MagicMock()
|
||||
response.headers = {}
|
||||
|
||||
async def iter_chunked(_chunk_size):
|
||||
yield b'\xff\xd8\xff\xe0jpeg-bytes'
|
||||
|
||||
response.content.iter_chunked = iter_chunked
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_get(url):
|
||||
yield response
|
||||
|
||||
fake_session = MagicMock()
|
||||
fake_session.get = fake_get
|
||||
|
||||
with patch(
|
||||
'langbot.pkg.platform.sources.telegram.httpclient.get_session',
|
||||
return_value=fake_session,
|
||||
):
|
||||
chain = await TelegramMessageConverter.target2yiri(message, MagicMock(), 'bot-account')
|
||||
|
||||
images = [c for c in chain if isinstance(c, platform_message.Image)]
|
||||
assert len(images) == 1
|
||||
image = images[0]
|
||||
|
||||
# The token-bearing URL must not be retained anywhere on the component.
|
||||
assert not image.url
|
||||
assert image.base64 is not None
|
||||
assert image.base64.startswith('data:image/jpeg;base64,')
|
||||
|
||||
# Belt-and-suspenders: the token must not appear in the serialized chain
|
||||
# (this is what gets persisted to the monitoring DB and sent to plugins).
|
||||
serialized = json.dumps(chain.model_dump(), ensure_ascii=False)
|
||||
assert TELEGRAM_BOT_TOKEN not in serialized
|
||||
assert 'api.telegram.org/file/bot' not in serialized
|
||||
|
||||
|
||||
def _select_form_data() -> dict:
|
||||
return {
|
||||
'_current_input_field': 'choice',
|
||||
|
||||
@@ -60,10 +60,12 @@ class TestBuildHeartbeatPayload:
|
||||
async def test_payload_shape(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||
|
||||
assert payload['event_type'] == 'instance_heartbeat'
|
||||
assert payload['query_id'] == ''
|
||||
assert payload['workspace_uuid'] == 'workspace-a'
|
||||
assert 'instance_id' not in payload
|
||||
assert 'instance_create_ts' in payload
|
||||
assert 'timestamp' in payload
|
||||
f = payload['features']
|
||||
@@ -86,7 +88,7 @@ class TestBuildHeartbeatPayload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_is_json_serializable(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
||||
json.dumps(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -94,7 +96,7 @@ class TestBuildHeartbeatPayload:
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||
assert payload['features']['pipeline_count'] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -106,31 +108,71 @@ class TestBuildHeartbeatPayload:
|
||||
side_effect=AssertionError('Cloud heartbeat must not issue per-tenant COUNTs')
|
||||
)
|
||||
ap.pipeline_mgr = SimpleNamespace(
|
||||
_pipelines_by_key={'pipeline-a': object(), 'pipeline-b': object()},
|
||||
_pipelines_by_key={
|
||||
('instance-a', 'workspace-a', 'pipeline-a'): object(),
|
||||
('instance-a', 'workspace-a', 'pipeline-b'): object(),
|
||||
},
|
||||
)
|
||||
adapter_a = type('WorkspaceAAdapter', (), {})()
|
||||
adapter_b = type('WorkspaceBAdapter', (), {})()
|
||||
ap.platform_mgr._bots_by_key = {
|
||||
('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a),
|
||||
}
|
||||
ap.tool_mgr = SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(
|
||||
_sessions={'mcp-a': object(), 'mcp-b': object(), 'mcp-c': object()},
|
||||
_sessions={
|
||||
('instance-a', 'workspace-a', 1, 'mcp-a'): object(),
|
||||
('instance-a', 'workspace-a', 1, 'mcp-b'): object(),
|
||||
('instance-a', 'workspace-a', 1, 'mcp-c'): object(),
|
||||
},
|
||||
),
|
||||
)
|
||||
ap.rag_mgr = SimpleNamespace(
|
||||
knowledge_bases={'kb-a': object()},
|
||||
knowledge_bases={('workspace-a', 'kb-a'): object()},
|
||||
)
|
||||
ap.plugin_connector._workspace_installations = {
|
||||
'workspace-a': {'plugin-a', 'plugin-b'},
|
||||
}
|
||||
ap.skill_mgr._skills_by_scope = {
|
||||
('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}},
|
||||
('instance-a', 'workspace-b', 1): {'skill-c': {}},
|
||||
}
|
||||
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(workspace_uuid='workspace-a'),
|
||||
SimpleNamespace(workspace_uuid='workspace-b'),
|
||||
],
|
||||
)
|
||||
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
||||
enable=True, adapter=adapter_b
|
||||
)
|
||||
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
||||
|
||||
features = payload['features']
|
||||
assert features['pipeline_count'] == 2
|
||||
assert features['mcp_server_count'] == 3
|
||||
assert features['knowledge_base_count'] == 1
|
||||
assert features['bot_count'] == 1
|
||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
||||
assert all('instance_id' not in payload for payload in payloads)
|
||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
||||
assert by_workspace['workspace-a']['knowledge_base_count'] == 1
|
||||
assert by_workspace['workspace-a']['bot_count'] == 1
|
||||
assert by_workspace['workspace-a']['plugin_count'] == 2
|
||||
assert by_workspace['workspace-a']['extension_count'] == 5
|
||||
assert by_workspace['workspace-a']['skill_count'] == 2
|
||||
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
|
||||
assert by_workspace['workspace-b']['bot_count'] == 1
|
||||
assert by_workspace['workspace-b']['pipeline_count'] == 0
|
||||
assert by_workspace['workspace-b']['skill_count'] == 1
|
||||
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
|
||||
assert 'workspace_resources' not in by_workspace['workspace-a']
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
ap.workspace_service.list_active_execution_bindings.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_content_fields(self):
|
||||
"""The heartbeat must never carry message content / credentials keys."""
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
||||
flat = json.dumps(payload).lower()
|
||||
for forbidden in ('api_key', 'password', 'token', 'message_content'):
|
||||
assert forbidden not in flat
|
||||
|
||||
@@ -569,6 +569,33 @@ class TestHTTPScenarios:
|
||||
await manager.send({'query_id': 'test'})
|
||||
|
||||
|
||||
class TestTelemetryManagedRuntimeAuthentication:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_includes_managed_runtime_token_header(self):
|
||||
telemetry = get_telemetry_module()
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
manager = telemetry.TelemetryManager(mock_app)
|
||||
manager.telemetry_config = {'url': 'https://example.com'}
|
||||
captured = {}
|
||||
|
||||
async def mock_post(url, json, headers):
|
||||
captured['headers'] = headers
|
||||
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
|
||||
|
||||
mock_client = Mock()
|
||||
mock_client.post = mock_post
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}),
|
||||
patch.object(httpx, 'AsyncClient', return_value=mock_client),
|
||||
):
|
||||
await manager.send({'event_type': 'instance_heartbeat'})
|
||||
|
||||
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
||||
|
||||
|
||||
class TestStartSendTask:
|
||||
"""Tests for start_send_task() method."""
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
|
||||
|
||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
||||
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
|
||||
|
||||
|
||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
first = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
second = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
|
||||
assert first == second
|
||||
assert str(uuid.UUID(first)) == first
|
||||
|
||||
|
||||
def test_query_telemetry_identity_uses_execution_workspace_only():
|
||||
from langbot.pkg.telemetry.identity import workspace_identity
|
||||
|
||||
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
|
||||
|
||||
assert identity == {"workspace_uuid": "workspace-a"}
|
||||
@@ -26,6 +26,7 @@ from langbot.pkg.workspace import (
|
||||
WorkspaceOwnerAlreadyExistsError,
|
||||
WorkspaceService,
|
||||
)
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||
|
||||
|
||||
@@ -113,6 +114,7 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context):
|
||||
first = await service.ensure_singleton_workspace()
|
||||
second = await service.ensure_singleton_workspace()
|
||||
|
||||
assert first.uuid == workspace_uuid_from_instance_id('instance_service_test')
|
||||
assert second.uuid == first.uuid
|
||||
async with session_factory() as session:
|
||||
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
beginAuthenticatedSession,
|
||||
beginSupportAdminSession,
|
||||
bootstrapWorkspaceSession,
|
||||
getPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
@@ -27,8 +28,10 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
|
||||
type SpaceOAuthLoginResult = {
|
||||
token: string;
|
||||
user: string;
|
||||
user?: string;
|
||||
workspace_uuid?: string;
|
||||
principal_type?: 'account' | 'support_admin';
|
||||
actor_account_uuid?: string;
|
||||
};
|
||||
|
||||
const pendingSpaceOAuthLogins = new Map<
|
||||
@@ -94,6 +97,16 @@ function SpaceOAuthCallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.principal_type === 'support_admin') {
|
||||
if (!response.workspace_uuid) {
|
||||
throw new Error('Support admin launch did not include a Workspace');
|
||||
}
|
||||
beginSupportAdminSession(response.token, response.workspace_uuid);
|
||||
await bootstrapWorkspaceSession();
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
if (getPendingInvitationToken()) {
|
||||
navigate('/invitations/accept', { replace: true });
|
||||
|
||||
@@ -166,6 +166,19 @@ export function SidebarDataProvider({
|
||||
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||
const pluginIconURLs = new Map<string, string>(
|
||||
await Promise.all(
|
||||
pluginsResp.plugins.map(async (plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const url = await httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.catch(() => '');
|
||||
return [`${author}/${name}`, url] as const;
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
@@ -184,7 +197,7 @@ export function SidebarDataProvider({
|
||||
const item: SidebarEntityItem = {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
iconURL: pluginIconURLs.get(compositeKey) || '',
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
@@ -218,7 +231,7 @@ export function SidebarDataProvider({
|
||||
pluginAuthor: author,
|
||||
pluginName: name,
|
||||
pluginLabel: label,
|
||||
pluginIconURL: httpClient.getPluginIconURL(author, name),
|
||||
pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
|
||||
pageId: page.id,
|
||||
path: page.path,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import {
|
||||
@@ -428,12 +429,9 @@ export default function KBForm({
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
@@ -459,12 +457,9 @@ export default function KBForm({
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{extractI18nObject(engine.name)}</span>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
bootstrapWorkspaceSession,
|
||||
systemInfo,
|
||||
initializeSystemInfo,
|
||||
isSupportAdminSession,
|
||||
useCurrentWorkspace,
|
||||
} from '@/app/infra/http';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -156,7 +157,7 @@ export default function HomeLayout({
|
||||
// selected Workspace's wizard state.
|
||||
useEffect(() => {
|
||||
if (!identityReady) return;
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) {
|
||||
navigate('/wizard', { replace: true });
|
||||
}
|
||||
}, [identityReady, navigate]);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -39,6 +39,11 @@ export default function ExtensionCardComponent({
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const authenticatedIcon = useAuthenticatedPluginIcon(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
cardVO.type === 'plugin',
|
||||
);
|
||||
|
||||
const FallbackIcon =
|
||||
cardVO.type === 'mcp'
|
||||
@@ -47,8 +52,8 @@ export default function ExtensionCardComponent({
|
||||
? Sparkles
|
||||
: Puzzle;
|
||||
const iconSrc =
|
||||
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
|
||||
const showFallback = iconFailed || !iconSrc;
|
||||
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
|
||||
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
|
||||
|
||||
const getTypeLabel = (type: ExtensionType) => {
|
||||
switch (type) {
|
||||
|
||||
+77
-43
@@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { getAPILanguageCode } from '@/i18n/I18nProvider';
|
||||
import '@/styles/github-markdown.css';
|
||||
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||
|
||||
function AuthenticatedReadmeImage({
|
||||
author,
|
||||
name,
|
||||
filepath,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
filepath: string;
|
||||
alt?: string;
|
||||
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const { url, error } = useAuthenticatedPluginAsset(author, name, filepath);
|
||||
if (error)
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">{alt || filepath}</span>
|
||||
);
|
||||
if (!url)
|
||||
return (
|
||||
<span className="inline-block h-6 w-24 animate-pulse rounded bg-muted" />
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginReadmeImage({
|
||||
author,
|
||||
name,
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const imageSrc = typeof src === 'string' ? src : '';
|
||||
if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) {
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let filepath = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
filepath = filepath.replace(/^assets\//, '');
|
||||
return (
|
||||
<AuthenticatedReadmeImage
|
||||
author={author}
|
||||
name={name}
|
||||
filepath={filepath}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginReadme({
|
||||
pluginAuthor,
|
||||
@@ -71,49 +139,15 @@ export default function PluginReadme({
|
||||
<ol className="list-decimal">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
img: ({ src, alt, ...props }) => {
|
||||
let imageSrc = src || '';
|
||||
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
imageSrc &&
|
||||
!imageSrc.startsWith('http://') &&
|
||||
!imageSrc.startsWith('https://') &&
|
||||
!imageSrc.startsWith('data:')
|
||||
) {
|
||||
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
|
||||
if (!imageSrc.startsWith('assets/')) {
|
||||
imageSrc = `assets/${imageSrc}`;
|
||||
}
|
||||
|
||||
const assetPath = imageSrc.replace(/^assets\//, '');
|
||||
imageSrc = httpClient.getPluginAssetURL(
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
assetPath,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<PluginReadmeImage
|
||||
author={pluginAuthor}
|
||||
name={pluginName}
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
|
||||
@@ -710,6 +710,32 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
||||
const response = await this.instance.get<Blob>(path, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return URL.createObjectURL(response.data);
|
||||
}
|
||||
|
||||
public getAuthenticatedPluginAssetURL(
|
||||
author: string,
|
||||
name: string,
|
||||
filepath: string,
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getAuthenticatedPluginIconURL(
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/api/v1/plugins/${author}/${name}/authenticated-icon`,
|
||||
);
|
||||
}
|
||||
|
||||
public async pluginPageApi(
|
||||
author: string,
|
||||
name: string,
|
||||
@@ -1327,8 +1353,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
launchAssertion?: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
user?: string;
|
||||
workspace_uuid?: string;
|
||||
principal_type?: 'account' | 'support_admin';
|
||||
actor_account_uuid?: string;
|
||||
}> {
|
||||
const response = await this.instance.post(
|
||||
'/api/v1/user/space/callback',
|
||||
|
||||
@@ -217,10 +217,34 @@ export function beginAuthenticatedSession(
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
localStorage.removeItem('authPrincipalType');
|
||||
localStorage.setItem('token', token);
|
||||
if (userEmail) localStorage.setItem('userEmail', userEmail);
|
||||
}
|
||||
|
||||
export function beginSupportAdminSession(
|
||||
token: string,
|
||||
workspaceUuid: string,
|
||||
): void {
|
||||
userInfo = null;
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('authPrincipalType', 'support_admin');
|
||||
setActiveWorkspaceUuid(workspaceUuid);
|
||||
}
|
||||
|
||||
export function isSupportAdminSession(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
localStorage.getItem('authPrincipalType') === 'support_admin'
|
||||
);
|
||||
}
|
||||
|
||||
async function initializeSelectedWorkspace(
|
||||
workspaceUuid: string,
|
||||
workspaces: WorkspaceBootstrapEntry[],
|
||||
@@ -252,6 +276,27 @@ async function initializeSelectedWorkspace(
|
||||
export async function bootstrapWorkspaceSession(
|
||||
options: WorkspaceBootstrapOptions = {},
|
||||
): Promise<WorkspaceBootstrapResult> {
|
||||
if (isSupportAdminSession()) {
|
||||
const selectedWorkspaceUuid = getActiveWorkspaceUuid();
|
||||
if (!selectedWorkspaceUuid) {
|
||||
throw new Error('Support admin session is missing its Workspace scope');
|
||||
}
|
||||
if (
|
||||
options.preferredWorkspaceUuid &&
|
||||
options.preferredWorkspaceUuid !== selectedWorkspaceUuid
|
||||
) {
|
||||
throw new Error('Support admin session cannot change Workspace scope');
|
||||
}
|
||||
await initializeWorkspaceInfo();
|
||||
const workspace = getCurrentWorkspaceSnapshot();
|
||||
if (!workspace || workspace.workspace.uuid !== selectedWorkspaceUuid) {
|
||||
clearWorkspaceSelection();
|
||||
throw new Error('Support admin Workspace scope could not be initialized');
|
||||
}
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
return { status: 'ready', workspace, workspaces: [] };
|
||||
}
|
||||
|
||||
if (options.resetSelection) {
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
@@ -339,6 +384,9 @@ export const clearUserInfo = (): void => {
|
||||
userInfo = null;
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('authPrincipalType');
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function AuthenticatedPluginIcon({
|
||||
author,
|
||||
name,
|
||||
alt = '',
|
||||
className,
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const icon = useAuthenticatedPluginIcon(
|
||||
author,
|
||||
name,
|
||||
Boolean(author && name),
|
||||
);
|
||||
|
||||
if (!icon.url || icon.error) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden={alt ? undefined : true}
|
||||
aria-label={alt || undefined}
|
||||
className={cn('inline-block bg-muted', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={icon.url} alt={alt} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
export function useAuthenticatedPluginIcon(
|
||||
author: string,
|
||||
name: string,
|
||||
enabled = true,
|
||||
): { url: string; error: boolean } {
|
||||
const [url, setURL] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setURL('');
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setURL('');
|
||||
setError(false);
|
||||
httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) setURL(nextURL);
|
||||
else URL.revokeObjectURL(nextURL);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, enabled, name]);
|
||||
|
||||
return { url, error };
|
||||
}
|
||||
|
||||
export function useAuthenticatedPluginAsset(
|
||||
author: string,
|
||||
name: string,
|
||||
filepath: string,
|
||||
): { url: string; error: boolean } {
|
||||
const [url, setURL] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setURL('');
|
||||
setError(false);
|
||||
httpClient
|
||||
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) setURL(nextURL);
|
||||
else URL.revokeObjectURL(nextURL);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, name, filepath]);
|
||||
|
||||
return { url, error };
|
||||
}
|
||||
@@ -175,6 +175,8 @@ const esES = {
|
||||
more: 'Más ({{count}})',
|
||||
less: 'Menos',
|
||||
noItems: 'Sin elementos',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Página no encontrada',
|
||||
@@ -324,6 +326,11 @@ const esES = {
|
||||
fallbackList: 'Modelos de respaldo',
|
||||
addFallback: 'Añadir modelo de respaldo',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Bots',
|
||||
@@ -1315,6 +1322,13 @@ const esES = {
|
||||
'Establece una contraseña para iniciar sesión con correo y contraseña',
|
||||
spaceEmailMismatch:
|
||||
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Panel de control',
|
||||
@@ -1573,6 +1587,8 @@ const esES = {
|
||||
api: 'API',
|
||||
storage: 'Almacenamiento',
|
||||
account: 'Cuenta',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1894,6 +1910,90 @@ const esES = {
|
||||
unsupportedFileType:
|
||||
'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
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. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default esES;
|
||||
|
||||
@@ -1378,6 +1378,11 @@ const jaJP = {
|
||||
operator: 'オペレーター',
|
||||
viewer: '閲覧者',
|
||||
},
|
||||
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'ダッシュボード',
|
||||
|
||||
@@ -172,6 +172,8 @@ const ruRU = {
|
||||
less: 'Свернуть',
|
||||
noItems: 'Нет элементов',
|
||||
termsOfService: 'Условия обслуживания',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Страница не найдена',
|
||||
@@ -323,6 +325,11 @@ const ruRU = {
|
||||
fallbackList: 'Резервные модели',
|
||||
addFallback: 'Добавить резервную модель',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Боты',
|
||||
@@ -1292,6 +1299,13 @@ const ruRU = {
|
||||
setPasswordHint: 'Установите пароль для входа с email и паролем',
|
||||
spaceEmailMismatch:
|
||||
'Email входа через Space не совпадает с email локальной учётной записи',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Мониторинг',
|
||||
@@ -1549,6 +1563,8 @@ const ruRU = {
|
||||
api: 'API',
|
||||
storage: 'Хранилище',
|
||||
account: 'Аккаунт',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1862,6 +1878,90 @@ const ruRU = {
|
||||
unsupportedFileType:
|
||||
'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
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. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ruRU;
|
||||
|
||||
@@ -169,6 +169,8 @@ const thTH = {
|
||||
more: 'เพิ่มเติม ({{count}})',
|
||||
less: 'น้อยลง',
|
||||
noItems: 'ไม่มีรายการ',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'ไม่พบหน้า',
|
||||
@@ -310,6 +312,11 @@ const thTH = {
|
||||
fallbackList: 'โมเดลสำรอง',
|
||||
addFallback: 'เพิ่มโมเดลสำรอง',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'บอท',
|
||||
@@ -1260,6 +1267,13 @@ const thTH = {
|
||||
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
||||
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'แดชบอร์ด',
|
||||
@@ -1516,6 +1530,8 @@ const thTH = {
|
||||
api: 'API',
|
||||
storage: 'พื้นที่จัดเก็บ',
|
||||
account: 'บัญชี',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1819,6 +1835,90 @@ const thTH = {
|
||||
createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง',
|
||||
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
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. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default thTH;
|
||||
|
||||
@@ -172,6 +172,8 @@ const viVN = {
|
||||
more: 'Thêm ({{count}})',
|
||||
less: 'Thu gọn',
|
||||
noItems: 'Không có mục nào',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Không tìm thấy trang',
|
||||
@@ -319,6 +321,11 @@ const viVN = {
|
||||
fallbackList: 'Mô hình dự phòng',
|
||||
addFallback: 'Thêm mô hình dự phòng',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Bot',
|
||||
@@ -1286,6 +1293,13 @@ const viVN = {
|
||||
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
|
||||
spaceEmailMismatch:
|
||||
'Email đăng nhập Space không khớp với email tài khoản cục bộ',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Bảng điều khiển',
|
||||
@@ -1542,6 +1556,8 @@ const viVN = {
|
||||
api: 'API',
|
||||
storage: 'Lưu trữ',
|
||||
account: 'Tài khoản',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1853,6 +1869,90 @@ const viVN = {
|
||||
unsupportedFileType:
|
||||
'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
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. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default viVN;
|
||||
|
||||
@@ -160,6 +160,8 @@ const zhHant = {
|
||||
more: '更多 ({{count}})',
|
||||
less: '收起',
|
||||
noItems: '暫無內容',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在',
|
||||
@@ -300,6 +302,11 @@ const zhHant = {
|
||||
fallbackList: '備用模型',
|
||||
addFallback: '新增備用模型',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
@@ -1216,6 +1223,13 @@ const zhHant = {
|
||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
||||
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: '儀表盤',
|
||||
@@ -1471,6 +1485,8 @@ const zhHant = {
|
||||
api: 'API',
|
||||
storage: '儲存',
|
||||
account: '帳戶',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1762,6 +1778,90 @@ const zhHant = {
|
||||
saveFileSuccess: '檔案儲存成功',
|
||||
saveFileError: '檔案儲存失敗:',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
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. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('support-admin launch stores a scoped principal instead of starting an Account session', () => {
|
||||
const callback = read('src/app/auth/space/callback/page.tsx');
|
||||
assert.match(callback, /response\.principal_type === 'support_admin'/);
|
||||
assert.match(
|
||||
callback,
|
||||
/beginSupportAdminSession\(response\.token, response\.workspace_uuid\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('support-admin workspace bootstrap never calls Account bootstrap', () => {
|
||||
const source = read('src/app/infra/http/index.ts');
|
||||
assert.match(source, /export function beginSupportAdminSession/);
|
||||
assert.match(source, /export function isSupportAdminSession\(\): boolean/);
|
||||
const supportBranch = source.indexOf('if (isSupportAdminSession())');
|
||||
const accountBootstrap = source.indexOf(
|
||||
'backendClient.getWorkspaceBootstrap()',
|
||||
);
|
||||
assert.ok(supportBranch >= 0);
|
||||
assert.ok(accountBootstrap > supportBranch);
|
||||
assert.match(
|
||||
source.slice(supportBranch, accountBootstrap),
|
||||
/initializeWorkspaceInfo\([\s\S]*status: 'ready'/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/localStorage\.setItem\('authPrincipalType', 'support_admin'\)/,
|
||||
);
|
||||
const homeLayout = read('src/app/home/layout.tsx');
|
||||
assert.match(homeLayout, /!isSupportAdminSession\(\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user