OpenSpace/openspace/cloud/account.py
2026-07-17 11:43:42 +08:00

169 lines
5.3 KiB
Python

"""User and agent-key lifecycle client for OpenSpace cloud auth."""
from __future__ import annotations
import json
import urllib.parse
from typing import Any, Dict
from openspace.cloud.base import cloud_api_url
from openspace.cloud.config import CloudConfig, require_cloud_enabled
from openspace.cloud.client import CloudError
from openspace.cloud.transport import (
CloudRequest,
CloudResponse,
CloudTransport,
UrllibCloudTransport,
)
class OpenSpaceAccountClient:
"""Synchronous client for cloud auth and agent-key management endpoints."""
_DEFAULT_UA = "OpenSpace-Client/1.0"
def __init__(
self,
config: CloudConfig,
transport: CloudTransport | None = None,
):
self._config = require_cloud_enabled(config)
self._transport = transport or UrllibCloudTransport()
def _request_json(
self,
method: str,
path: str,
*,
version: str = "v1",
payload: Dict[str, Any] | None = None,
headers: Dict[str, str] | None = None,
timeout: int = 30,
) -> Dict[str, Any] | list[Any]:
body = None
request_headers = {
"User-Agent": self._DEFAULT_UA,
**(headers or {}),
}
if payload is not None:
body = json.dumps(payload).encode("utf-8")
request_headers["Content-Type"] = "application/json"
response = self._transport.send(
CloudRequest(
method=method,
url=cloud_api_url(self._config.base_url, version, path),
headers=request_headers,
body=body,
timeout=timeout,
)
)
self._raise_for_status(response)
data = response.body
if not data:
return {}
return json.loads(data.decode("utf-8"))
@staticmethod
def _raise_for_status(response: CloudResponse) -> None:
if 200 <= response.status_code < 300:
return
body = response.body.decode("utf-8", errors="replace")
raise CloudError(
f"HTTP {response.status_code}: {body[:500]}",
status_code=response.status_code,
body=body,
)
@staticmethod
def _bearer_headers(access_token: str) -> Dict[str, str]:
return {"Authorization": f"Bearer {access_token}"}
def register_user(
self,
*,
email: str,
password: str,
name: str | None = None,
) -> Dict[str, Any]:
"""POST /api/v2/auth/users/register."""
payload: Dict[str, Any] = {"email": email, "password": password}
return self._request_json("POST", "/auth/users/register", version="v2", payload=payload)
def login_user(self, *, email: str, password: str) -> Dict[str, Any]:
"""POST /api/v2/auth/users/login."""
return self._request_json(
"POST",
"/auth/users/login",
version="v2",
payload={"email": email, "password": password},
)
def agent_bootstrap(
self,
*,
email: str,
password: str,
agent_name: str,
) -> Dict[str, Any]:
"""POST /api/v2/auth/agent-bootstrap.
This endpoint intentionally sends no Authorization, X-API-Key, or
X-Admin-Key headers. The returned ``api_key`` is shown once by server
contract and must be stored by the caller.
"""
return self._request_json(
"POST",
"/auth/agent-bootstrap",
version="v2",
payload={
"email": email,
"password": password,
"agent_name": agent_name,
},
)
def create_agent(self, *, access_token: str, name: str) -> Dict[str, Any]:
"""POST /api/v1/agents and return the one-time agent API key."""
return self._request_json(
"POST",
"/agents",
payload={"name": name},
headers=self._bearer_headers(access_token),
)
def list_agents(self, *, access_token: str) -> list[Dict[str, Any]]:
"""GET /api/v1/agents using a user bearer token."""
result = self._request_json(
"GET",
"/agents",
headers=self._bearer_headers(access_token),
)
if not isinstance(result, list):
raise CloudError("GET /api/v1/agents returned a non-list response")
return result
def rotate_agent_key(self, *, access_token: str, agent_id: str) -> Dict[str, Any]:
"""POST /api/v1/agents/{agent_id}/rotate-key."""
return self._request_json(
"POST",
f"/agents/{urllib.parse.quote(str(agent_id), safe='')}/rotate-key",
headers=self._bearer_headers(access_token),
)
def me(
self,
*,
access_token: str | None = None,
api_key: str | None = None,
) -> Dict[str, Any]:
"""GET /api/v1/auth/me for either user bearer or agent API key."""
if bool(access_token) == bool(api_key):
raise CloudError("Provide exactly one of access_token or api_key")
headers = (
self._bearer_headers(access_token)
if access_token
else {"X-API-Key": str(api_key)}
)
return self._request_json("GET", "/auth/me", headers=headers)