chore(python): audit core package
_client_common.py owns request building and EOR signing for both clients; types follow the Backend contract; the deprecated issue_token alias and token response aliases are removed; crypto, utils, and CLI comments trimmed. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
54e65eb56a
commit
0f72a5e2f1
10 files changed
+414
-706
No files matched your search
@@ -168,7 +168,7 @@ auth = ElydoraClient.login(base_url, email, password)
|
||||
me = client.get_me()
|
||||
|
||||
# Issue a new API token (with optional TTL in seconds)
|
||||
token_resp = client.issue_token(ttl_seconds=3600)
|
||||
token_resp = client.issue_api_token(ttl_seconds=3600)
|
||||
```
|
||||
|
||||
### Operations
|
||||
@@ -320,7 +320,7 @@ from elydora import (
|
||||
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
|
||||
ListEpochsResponse, CreateExportResponse, GetExportResponse,
|
||||
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
|
||||
AuthLoginResponse, GetMeResponse, IssueTokenResponse, HealthResponse,
|
||||
AuthLoginResponse, GetMeResponse, IssueApiTokenResponse, HealthResponse,
|
||||
|
||||
# Request types
|
||||
RegisterAgentRequest,
|
||||
|
||||
+8
-8
@@ -21,6 +21,10 @@ from .types import (
|
||||
AgentKey,
|
||||
AgentAssignment,
|
||||
AdminEvent,
|
||||
AuthMeUser,
|
||||
CurrentOrganization,
|
||||
DependencyHealth,
|
||||
ErrorCode,
|
||||
EpochAnchor,
|
||||
MemberEntry,
|
||||
Operation,
|
||||
@@ -65,8 +69,6 @@ from .types import (
|
||||
GetMeResponse,
|
||||
IssueApiTokenResponse,
|
||||
RotateApiTokenResponse,
|
||||
IssueTokenResponse,
|
||||
RotateTokenResponse,
|
||||
HealthResponse,
|
||||
DeepHealthResponse,
|
||||
)
|
||||
@@ -87,7 +89,6 @@ __all__ = [
|
||||
"base64url_decode",
|
||||
"generate_nonce",
|
||||
"generate_uuidv7",
|
||||
# Literal type aliases
|
||||
"AdminAction",
|
||||
"AgentStatus",
|
||||
"ExportStatus",
|
||||
@@ -95,13 +96,16 @@ __all__ = [
|
||||
"IntegrationType",
|
||||
"KeyStatus",
|
||||
"RbacRole",
|
||||
# Entity types
|
||||
"EOR",
|
||||
"EAR",
|
||||
"Agent",
|
||||
"AgentAssignment",
|
||||
"AgentKey",
|
||||
"AdminEvent",
|
||||
"AuthMeUser",
|
||||
"CurrentOrganization",
|
||||
"DependencyHealth",
|
||||
"ErrorCode",
|
||||
"EpochAnchor",
|
||||
"MemberEntry",
|
||||
"Operation",
|
||||
@@ -111,10 +115,8 @@ __all__ = [
|
||||
"Organization",
|
||||
"User",
|
||||
"WebhookEntry",
|
||||
# Request types
|
||||
"RegisterAgentRequest",
|
||||
"UpdateAgentRequest",
|
||||
# Response types
|
||||
"RegisterAgentResponse",
|
||||
"UpdateAgentResponse",
|
||||
"GetAgentResponse",
|
||||
@@ -141,8 +143,6 @@ __all__ = [
|
||||
"GetMeResponse",
|
||||
"IssueApiTokenResponse",
|
||||
"RotateApiTokenResponse",
|
||||
"IssueTokenResponse",
|
||||
"RotateTokenResponse",
|
||||
"HealthResponse",
|
||||
"DeepHealthResponse",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Request and response building shared by the sync and async clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .types import EOR
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
DEFAULT_BASE_URL = "https://api.elydora.com"
|
||||
REQUEST_TIMEOUT_SECONDS = 30
|
||||
|
||||
|
||||
def build_headers(token: Optional[str]) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def error_from_response(status_code: int, body: Any, text: str) -> ElydoraError:
|
||||
error = body.get("error", {}) if isinstance(body, dict) else {}
|
||||
return ElydoraError(
|
||||
code=error.get("code", "INTERNAL_ERROR"),
|
||||
message=error.get("message") or text or "Unknown error",
|
||||
request_id=error.get("request_id", ""),
|
||||
details=error.get("details"),
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def without_none(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in values.items() if value is not None}
|
||||
|
||||
|
||||
def register_body(
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: Optional[str],
|
||||
org_name: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
return without_none({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"display_name": display_name,
|
||||
"org_name": org_name,
|
||||
})
|
||||
|
||||
|
||||
def api_token_body(ttl_seconds: Optional[int]) -> Dict[str, Any]:
|
||||
return without_none({"ttl_seconds": ttl_seconds})
|
||||
|
||||
|
||||
def export_body(
|
||||
start_time: int,
|
||||
end_time: int,
|
||||
format: str,
|
||||
agent_id: Optional[str],
|
||||
operation_type: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
return without_none({
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
})
|
||||
|
||||
|
||||
def admin_events_params(limit: Optional[int]) -> Dict[str, str]:
|
||||
return {} if limit is None else {"limit": str(limit)}
|
||||
|
||||
|
||||
def build_eor(
|
||||
*,
|
||||
org_id: str,
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
kid: str,
|
||||
ttl_ms: int,
|
||||
prev_chain_hash: str,
|
||||
operation_type: str,
|
||||
subject: Dict[str, Any],
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None],
|
||||
) -> Tuple[EOR, str]:
|
||||
"""Build and sign one EOR; returns the record and its chain hash."""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": ttl_ms,
|
||||
"nonce": generate_nonce(),
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": prev_chain_hash,
|
||||
"agent_pubkey_kid": kid,
|
||||
"signature": "",
|
||||
}
|
||||
eor["signature"] = sign_eor(eor, private_key)
|
||||
return eor, chain_hash # type: ignore[return-value]
|
||||
+113
-254
@@ -2,16 +2,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ._async_http import request_with_retries
|
||||
from ._client_common import (
|
||||
DEFAULT_BASE_URL,
|
||||
GENESIS_CHAIN_HASH,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
admin_events_params,
|
||||
api_token_body,
|
||||
build_eor,
|
||||
build_headers,
|
||||
error_from_response,
|
||||
export_body,
|
||||
register_body,
|
||||
without_none,
|
||||
)
|
||||
from ._retry import require_max_retries
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
@@ -21,35 +31,33 @@ from .types import (
|
||||
DeepHealthResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
FreezeAgentResponse,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueApiTokenResponse,
|
||||
IntegrationType,
|
||||
ListAdminEventsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RotateApiTokenResponse,
|
||||
IssueApiTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAdminEventsResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
RegisterWebhookResponse,
|
||||
RotateApiTokenResponse,
|
||||
SubmitOperationResponse,
|
||||
UnfreezeAgentResponse,
|
||||
UpdateAgentResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
TIMEOUT = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class AsyncElydoraClient:
|
||||
@@ -61,7 +69,7 @@ class AsyncElydoraClient:
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
max_retries: Retries after the initial attempt on transient failures.
|
||||
token: Optional API token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
@@ -71,7 +79,7 @@ class AsyncElydoraClient:
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
@@ -83,17 +91,14 @@ class AsyncElydoraClient:
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = require_max_retries(max_retries)
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = agent_id + '-key-1'
|
||||
self._kid = agent_id + "-key-1"
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
@@ -102,20 +107,9 @@ class AsyncElydoraClient:
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -123,51 +117,43 @@ class AsyncElydoraClient:
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
session = await self._get_session()
|
||||
|
||||
return await request_with_retries(
|
||||
session,
|
||||
await self._get_session(),
|
||||
method,
|
||||
url,
|
||||
f"{self.base_url}{path}",
|
||||
path=path,
|
||||
max_retries=self.max_retries,
|
||||
response_handler=self._handle_response,
|
||||
json_body=json_body,
|
||||
params=params,
|
||||
headers=hdrs,
|
||||
headers=build_headers(self.token),
|
||||
)
|
||||
|
||||
async def _get_public(self, path: str) -> Any:
|
||||
session = await self._get_session()
|
||||
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response:
|
||||
return await self._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
|
||||
if resp.status >= 400:
|
||||
try:
|
||||
body = await resp.json()
|
||||
except Exception:
|
||||
text = await resp.text()
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=text or "Unknown error",
|
||||
status_code=resp.status,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", ""),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status,
|
||||
)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
body = None
|
||||
raise error_from_response(resp.status, body, await resp.text())
|
||||
if resp.status == 204:
|
||||
return None
|
||||
return await resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
@staticmethod
|
||||
async def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers=build_headers(None), timeout=TIMEOUT
|
||||
) as response:
|
||||
return await AsyncElydoraClient._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
async def register(
|
||||
@@ -177,113 +163,75 @@ class AsyncElydoraClient:
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
return await AsyncElydoraClient._post_json(
|
||||
url, register_body(email, password, display_name, org_name)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a session token.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
return await AsyncElydoraClient._handle_response(resp)
|
||||
return await AsyncElydoraClient._post_json(url, {"email": email, "password": password})
|
||||
|
||||
async def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return await self._request("GET", "/v1/auth/me")
|
||||
|
||||
async def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return await self._request("POST", "/v1/auth/token", json_body=body)
|
||||
|
||||
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Deprecated: Use issue_api_token() instead."""
|
||||
return await self.issue_api_token(ttl_seconds=ttl_seconds)
|
||||
return await self._request(
|
||||
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds)
|
||||
)
|
||||
|
||||
async def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||
"""Rotate the current API token and keep the old token in 24h grace."""
|
||||
return await self._request("POST", "/v1/auth/rotate", json_body={})
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return await self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
|
||||
async def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return await self._request("GET", "/v1/agents")
|
||||
|
||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||
"""Unfreeze an agent."""
|
||||
return await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||
return await self._request(
|
||||
"POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}
|
||||
)
|
||||
|
||||
async def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||
"""Update an agent's integration type."""
|
||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||
return await self._request(
|
||||
"POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}
|
||||
)
|
||||
|
||||
async def update_agent(
|
||||
self, agent_id: str, integration_type: IntegrationType
|
||||
) -> UpdateAgentResponse:
|
||||
require_integration_type(integration_type)
|
||||
return await self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
||||
return await self._request(
|
||||
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
|
||||
)
|
||||
|
||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return await self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
|
||||
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
@@ -291,58 +239,32 @@ class AsyncElydoraClient:
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This is synchronous because it only performs local crypto operations.
|
||||
Call submit_operation() with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||
eor, chain_hash = build_eor(
|
||||
org_id=self.org_id,
|
||||
agent_id=self.agent_id,
|
||||
private_key=self.private_key,
|
||||
kid=self._kid,
|
||||
ttl_ms=self.ttl_ms,
|
||||
prev_chain_hash=self._prev_chain_hash,
|
||||
operation_type=operation_type,
|
||||
subject=subject,
|
||||
action=action,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
return eor
|
||||
|
||||
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return await self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
async def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return await self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return await self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
return await self._request(
|
||||
"POST", f"/v1/operations/{operation_id}/verify", json_body={}
|
||||
)
|
||||
|
||||
async def query_audit(
|
||||
self,
|
||||
@@ -355,40 +277,23 @@ class AsyncElydoraClient:
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
body = without_none({
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
})
|
||||
return await self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return await self._request("GET", "/v1/epochs")
|
||||
|
||||
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return await self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
@@ -397,97 +302,51 @@ class AsyncElydoraClient:
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||
return await self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
async def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return await self._request("GET", "/v1/exports")
|
||||
|
||||
async def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return await self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
async def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
session = await self._get_session()
|
||||
async with session.get(
|
||||
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
await self._handle_response(resp)
|
||||
return await resp.read()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Webhooks
|
||||
# -----------------------------------------------------------------
|
||||
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||
headers=build_headers(self.token),
|
||||
timeout=TIMEOUT,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
await self._handle_response(response)
|
||||
return await response.read()
|
||||
|
||||
async def list_webhooks(self) -> ListWebhooksResponse:
|
||||
"""List all webhooks for the organization."""
|
||||
return await self._request("GET", "/v1/webhooks")
|
||||
|
||||
async def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
|
||||
"""Register a new webhook."""
|
||||
return await self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
|
||||
async def register_webhook(
|
||||
self, endpoint_url: str, events: List[str], secret: str
|
||||
) -> RegisterWebhookResponse:
|
||||
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||
return await self._request("POST", "/v1/webhooks", json_body=body)
|
||||
|
||||
async def delete_webhook(self, webhook_id: str) -> None:
|
||||
"""Delete a webhook."""
|
||||
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Members
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def list_members(self) -> ListMembersResponse:
|
||||
"""List all members in the organization."""
|
||||
return await self._request("GET", "/v1/members")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Admin events
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||
"""List recent admin events."""
|
||||
params: Dict[str, str] = {}
|
||||
if limit is not None:
|
||||
params["limit"] = str(limit)
|
||||
return await self._request("GET", "/v1/admin/events", params=params)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
return await self._request(
|
||||
"GET", "/v1/admin/events", params=admin_events_params(limit)
|
||||
)
|
||||
|
||||
async def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
return await self._get_public("/.well-known/elydora/jwks.json")
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
return await self._get_public("/v1/health")
|
||||
|
||||
async def deep_health(self) -> DeepHealthResponse:
|
||||
"""Check API deep health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health/deep"
|
||||
session = await self._get_session()
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
return await self._handle_response(resp)
|
||||
return await self._get_public("/v1/health/deep")
|
||||
+5
-24
@@ -1,8 +1,4 @@
|
||||
"""Elydora CLI — install, uninstall, and manage agent audit hooks.
|
||||
|
||||
Entry point: ``elydora`` console script (see pyproject.toml).
|
||||
Uses only stdlib argparse — zero external dependencies for the CLI itself.
|
||||
"""
|
||||
"""Elydora CLI: install, uninstall, and inspect agent audit hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -173,17 +169,15 @@ def _discover_installed_agents_or_exit() -> list[_InstalledAgent]:
|
||||
|
||||
|
||||
def _get_plugin(agent_name: str) -> AgentPlugin:
|
||||
"""Instantiate the plugin for the given agent name."""
|
||||
cls = PLUGIN_MAP.get(agent_name)
|
||||
if cls is None:
|
||||
print(f"Error: Unknown agent '{agent_name}'.", file=sys.stderr)
|
||||
print(f"Supported agents: {', '.join(get_agent_names())}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_exit_with_error(
|
||||
f"Unknown agent {agent_name!r}. Supported agents: {', '.join(get_agent_names())}"
|
||||
)
|
||||
return cls()
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'install' subcommand."""
|
||||
recover_pending_transactions()
|
||||
agent_name: str = args.agent
|
||||
agent_dir = _resolve_agent_directory_or_exit(args.agent_id)
|
||||
@@ -197,12 +191,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
_exit_with_error(str(error))
|
||||
|
||||
# Derive public key to verify the private key is valid
|
||||
try:
|
||||
pub = get_public_key_base64url(secrets.private_key)
|
||||
except Exception as exc:
|
||||
print(f"Error: Invalid private key — {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_exit_with_error(f"Invalid private key: {exc}")
|
||||
|
||||
print(f"Verified key pair (public key: {pub[:16]}...)")
|
||||
|
||||
@@ -220,12 +212,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
config["token"] = secrets.token
|
||||
plugin.preflight_install(config)
|
||||
|
||||
# Transactional adapters create and recover their own runtime directories.
|
||||
if not getattr(plugin, "manages_runtime_directories", False):
|
||||
ensure_private_directory(runtime_root())
|
||||
ensure_private_directory(agent_dir)
|
||||
|
||||
# Generate and write the guard script for adapters that use the shared runtime.
|
||||
if not plugin.manages_guard_runtime:
|
||||
guard_script = generate_guard_script(agent_name, args.agent_id)
|
||||
write_text_atomic(
|
||||
@@ -240,7 +230,6 @@ def cmd_install(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'uninstall' subcommand."""
|
||||
recover_pending_transactions()
|
||||
explicit_agent_id = getattr(args, "agent_id", None)
|
||||
elydora_dir = runtime_root()
|
||||
@@ -296,7 +285,6 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'status' subcommand."""
|
||||
recover_pending_transactions()
|
||||
print("Elydora Agent Hook Status")
|
||||
print("=" * 40)
|
||||
@@ -309,7 +297,6 @@ def cmd_status(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def cmd_agents(args: argparse.Namespace) -> None:
|
||||
"""Handle the 'agents' subcommand."""
|
||||
print("Supported agents:")
|
||||
for name in get_agent_names():
|
||||
info = SUPPORTED_AGENTS[name]
|
||||
@@ -317,7 +304,6 @@ def cmd_agents(args: argparse.Namespace) -> None:
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the argparse parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="elydora",
|
||||
description="Elydora — tamper-evident audit trail for AI agents",
|
||||
@@ -328,7 +314,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||
|
||||
# install
|
||||
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
|
||||
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
|
||||
install_parser.add_argument("--org_id", required=True, help="Organization ID")
|
||||
@@ -346,22 +331,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
|
||||
|
||||
# uninstall
|
||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
|
||||
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
|
||||
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
|
||||
|
||||
# status
|
||||
subparsers.add_parser("status", help="Show installation status of all agents")
|
||||
|
||||
# agents
|
||||
subparsers.add_parser("agents", help="List supported agents")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
_reject_legacy_secret_arguments(sys.argv[1:])
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
+95
-239
@@ -2,16 +2,26 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
from ._client_common import (
|
||||
DEFAULT_BASE_URL,
|
||||
GENESIS_CHAIN_HASH,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
admin_events_params,
|
||||
api_token_body,
|
||||
build_eor,
|
||||
build_headers,
|
||||
error_from_response,
|
||||
export_body,
|
||||
register_body,
|
||||
without_none,
|
||||
)
|
||||
from ._retry import require_max_retries
|
||||
from ._sync_http import request_with_retries
|
||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||
from .errors import ElydoraError
|
||||
from .integration_types import require_integration_type
|
||||
from .types import (
|
||||
AuditQueryResponse,
|
||||
@@ -21,35 +31,31 @@ from .types import (
|
||||
DeepHealthResponse,
|
||||
DeleteAgentResponse,
|
||||
EOR,
|
||||
FreezeAgentResponse,
|
||||
GetAgentResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
GetMeResponse,
|
||||
GetOperationResponse,
|
||||
HealthResponse,
|
||||
IssueApiTokenResponse,
|
||||
IntegrationType,
|
||||
ListAdminEventsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RotateApiTokenResponse,
|
||||
IssueApiTokenResponse,
|
||||
JWKSResponse,
|
||||
ListAdminEventsResponse,
|
||||
ListAgentsResponse,
|
||||
ListEpochsResponse,
|
||||
ListExportsResponse,
|
||||
ListMembersResponse,
|
||||
ListWebhooksResponse,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
RegisterWebhookResponse,
|
||||
RotateApiTokenResponse,
|
||||
SubmitOperationResponse,
|
||||
UnfreezeAgentResponse,
|
||||
UpdateAgentResponse,
|
||||
VerifyOperationResponse,
|
||||
)
|
||||
from .utils import generate_nonce, generate_uuidv7
|
||||
|
||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
|
||||
|
||||
class ElydoraClient:
|
||||
@@ -61,7 +67,7 @@ class ElydoraClient:
|
||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||
base_url: API base URL.
|
||||
ttl_ms: Time-to-live for operations in milliseconds.
|
||||
max_retries: Maximum number of retries on transient failures.
|
||||
max_retries: Retries after the initial attempt on transient failures.
|
||||
token: Optional API token for authenticated endpoints.
|
||||
"""
|
||||
|
||||
@@ -71,7 +77,7 @@ class ElydoraClient:
|
||||
agent_id: str,
|
||||
private_key: str,
|
||||
*,
|
||||
base_url: str = "https://api.elydora.com",
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
ttl_ms: int = 30000,
|
||||
max_retries: int = 3,
|
||||
token: Optional[str] = None,
|
||||
@@ -83,29 +89,16 @@ class ElydoraClient:
|
||||
self.ttl_ms = ttl_ms
|
||||
self.max_retries = require_max_retries(max_retries)
|
||||
self.token = token
|
||||
|
||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||
self._kid = agent_id + '-key-1'
|
||||
self._kid = agent_id + "-key-1"
|
||||
self._session = requests.Session()
|
||||
|
||||
def set_kid(self, kid: str) -> None:
|
||||
"""Set the key ID used for signing operations."""
|
||||
self._kid = kid
|
||||
|
||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||
"""Set the previous chain hash for the next operation."""
|
||||
self._prev_chain_hash = prev_chain_hash
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Internal HTTP helpers
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -113,49 +106,44 @@ class ElydoraClient:
|
||||
*,
|
||||
json_body: Any = None,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
hdrs = headers or self._headers()
|
||||
|
||||
return request_with_retries(
|
||||
self._session,
|
||||
method,
|
||||
url,
|
||||
f"{self.base_url}{path}",
|
||||
path=path,
|
||||
max_retries=self.max_retries,
|
||||
response_handler=self._handle_response,
|
||||
json_body=json_body,
|
||||
params=params,
|
||||
headers=hdrs,
|
||||
headers=build_headers(self.token),
|
||||
)
|
||||
|
||||
def _get_public(self, path: str) -> Any:
|
||||
response = self._session.get(f"{self.base_url}{path}", timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return self._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
def _handle_response(resp: requests.Response) -> Any:
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
raise ElydoraError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=resp.text or "Unknown error",
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
err = body.get("error", {})
|
||||
raise ElydoraError(
|
||||
code=err.get("code", "INTERNAL_ERROR"),
|
||||
message=err.get("message", resp.text),
|
||||
request_id=err.get("request_id", ""),
|
||||
details=err.get("details"),
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
except ValueError:
|
||||
body = None
|
||||
raise error_from_response(resp.status_code, body, resp.text)
|
||||
if resp.status_code == 204:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Auth (static methods)
|
||||
# -----------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=build_headers(None),
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
return ElydoraClient._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
def register(
|
||||
@@ -165,107 +153,65 @@ class ElydoraClient:
|
||||
display_name: Optional[str] = None,
|
||||
org_name: Optional[str] = None,
|
||||
) -> AuthRegisterResponse:
|
||||
"""Register a new user and organization.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||
body: Dict[str, Any] = {"email": email, "password": password}
|
||||
if display_name is not None:
|
||||
body["display_name"] = display_name
|
||||
if org_name is not None:
|
||||
body["org_name"] = org_name
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
return ElydoraClient._post_json(url, register_body(email, password, display_name, org_name))
|
||||
|
||||
@staticmethod
|
||||
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||
"""Authenticate and receive a session token.
|
||||
|
||||
.. deprecated::
|
||||
Use Better Auth endpoints directly. See docs.
|
||||
"""
|
||||
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||
warnings.warn(
|
||||
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
||||
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||
body = {"email": email, "password": password}
|
||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
||||
return ElydoraClient._handle_response(resp)
|
||||
return ElydoraClient._post_json(url, {"email": email, "password": password})
|
||||
|
||||
def get_me(self) -> GetMeResponse:
|
||||
"""Get the current user's profile."""
|
||||
return self._request("GET", "/v1/auth/me")
|
||||
|
||||
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Issue an API token with an optional TTL."""
|
||||
body: Dict[str, Any] = {}
|
||||
if ttl_seconds is not None:
|
||||
body["ttl_seconds"] = ttl_seconds
|
||||
return self._request("POST", "/v1/auth/token", json_body=body)
|
||||
|
||||
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||
"""Deprecated: Use issue_api_token() instead."""
|
||||
return self.issue_api_token(ttl_seconds=ttl_seconds)
|
||||
return self._request("POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds))
|
||||
|
||||
def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||
"""Rotate the current API token and keep the old token in 24h grace."""
|
||||
return self._request("POST", "/v1/auth/rotate", json_body={})
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Agent management
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||
"""Register a new agent with the organization."""
|
||||
require_integration_type(request.get("integration_type"))
|
||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||
|
||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||
"""Retrieve agent details and keys."""
|
||||
return self._request("GET", f"/v1/agents/{agent_id}")
|
||||
|
||||
def freeze_agent(self, agent_id: str, reason: str) -> None:
|
||||
"""Freeze an agent."""
|
||||
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
|
||||
def list_agents(self) -> ListAgentsResponse:
|
||||
"""List all agents for the organization."""
|
||||
return self._request("GET", "/v1/agents")
|
||||
|
||||
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||
return self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||
|
||||
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||
"""Unfreeze an agent."""
|
||||
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||
|
||||
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||
"""Update an agent's integration type."""
|
||||
require_integration_type(integration_type)
|
||||
return self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
||||
return self._request(
|
||||
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
|
||||
)
|
||||
|
||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||
"""Delete an agent."""
|
||||
return self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||
|
||||
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||
"""Revoke an agent's key."""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/v1/agents/{agent_id}/revoke",
|
||||
json_body={"kid": kid, "reason": reason},
|
||||
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Operations (CORE)
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_operation(
|
||||
self,
|
||||
operation_type: str,
|
||||
@@ -273,61 +219,31 @@ class ElydoraClient:
|
||||
action: Dict[str, Any],
|
||||
payload: Union[Dict[str, Any], str, None] = None,
|
||||
) -> EOR:
|
||||
"""Build and sign an Elydora Operation Record (EOR).
|
||||
|
||||
This does NOT submit the operation to the server. Call submit_operation()
|
||||
with the returned EOR to submit it.
|
||||
"""
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
nonce = generate_nonce()
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(
|
||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
||||
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||
eor, chain_hash = build_eor(
|
||||
org_id=self.org_id,
|
||||
agent_id=self.agent_id,
|
||||
private_key=self.private_key,
|
||||
kid=self._kid,
|
||||
ttl_ms=self.ttl_ms,
|
||||
prev_chain_hash=self._prev_chain_hash,
|
||||
operation_type=operation_type,
|
||||
subject=subject,
|
||||
action=action,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
eor: Dict[str, Any] = {
|
||||
"op_version": "1.0",
|
||||
"operation_id": operation_id,
|
||||
"org_id": self.org_id,
|
||||
"agent_id": self.agent_id,
|
||||
"issued_at": issued_at,
|
||||
"ttl_ms": self.ttl_ms,
|
||||
"nonce": nonce,
|
||||
"operation_type": operation_type,
|
||||
"subject": subject,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"payload_hash": payload_hash,
|
||||
"prev_chain_hash": self._prev_chain_hash,
|
||||
"agent_pubkey_kid": self._kid,
|
||||
"signature": "",
|
||||
}
|
||||
|
||||
# Sign the EOR (signature field excluded from canonical form)
|
||||
eor["signature"] = sign_eor(eor, self.private_key)
|
||||
|
||||
# Update chain state
|
||||
self._prev_chain_hash = chain_hash
|
||||
|
||||
return eor # type: ignore[return-value]
|
||||
return eor
|
||||
|
||||
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||
"""Submit a signed EOR to the server."""
|
||||
return self._request("POST", "/v1/operations", json_body=eor)
|
||||
|
||||
def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||
"""Retrieve an operation by ID."""
|
||||
return self._request("GET", f"/v1/operations/{operation_id}")
|
||||
|
||||
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||
"""Verify an operation's integrity."""
|
||||
return self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Audit
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def query_audit(
|
||||
self,
|
||||
*,
|
||||
@@ -339,40 +255,23 @@ class ElydoraClient:
|
||||
cursor: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AuditQueryResponse:
|
||||
"""Query the tamper-evident audit log."""
|
||||
body: Dict[str, Any] = {}
|
||||
if org_id is not None:
|
||||
body["org_id"] = org_id
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
if start_time is not None:
|
||||
body["start_time"] = start_time
|
||||
if end_time is not None:
|
||||
body["end_time"] = end_time
|
||||
if cursor is not None:
|
||||
body["cursor"] = cursor
|
||||
if limit is not None:
|
||||
body["limit"] = limit
|
||||
body = without_none({
|
||||
"org_id": org_id,
|
||||
"agent_id": agent_id,
|
||||
"operation_type": operation_type,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
})
|
||||
return self._request("POST", "/v1/audit/query", json_body=body)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Epochs
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def list_epochs(self) -> ListEpochsResponse:
|
||||
"""List all epochs for the organization."""
|
||||
return self._request("GET", "/v1/epochs")
|
||||
|
||||
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||
"""Retrieve an epoch root record."""
|
||||
return self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Exports
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def create_export(
|
||||
self,
|
||||
start_time: int,
|
||||
@@ -381,91 +280,48 @@ class ElydoraClient:
|
||||
agent_id: Optional[str] = None,
|
||||
operation_type: Optional[str] = None,
|
||||
) -> CreateExportResponse:
|
||||
"""Create a compliance export job."""
|
||||
body: Dict[str, Any] = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"format": format,
|
||||
}
|
||||
if agent_id is not None:
|
||||
body["agent_id"] = agent_id
|
||||
if operation_type is not None:
|
||||
body["operation_type"] = operation_type
|
||||
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||
return self._request("POST", "/v1/exports", json_body=body)
|
||||
|
||||
def list_exports(self) -> ListExportsResponse:
|
||||
"""List all exports for the organization."""
|
||||
return self._request("GET", "/v1/exports")
|
||||
|
||||
def get_export(self, export_id: str) -> GetExportResponse:
|
||||
"""Retrieve export status and download URL."""
|
||||
return self._request("GET", f"/v1/exports/{export_id}")
|
||||
|
||||
def download_export(self, export_id: str) -> bytes:
|
||||
"""Download an export file as raw bytes."""
|
||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
||||
resp = self._session.get(url, headers=self._headers(), timeout=30)
|
||||
if resp.status_code >= 400:
|
||||
self._handle_response(resp)
|
||||
return resp.content
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Webhooks
|
||||
# -----------------------------------------------------------------
|
||||
response = self._session.get(
|
||||
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||
headers=build_headers(self.token),
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
self._handle_response(response)
|
||||
return response.content
|
||||
|
||||
def list_webhooks(self) -> ListWebhooksResponse:
|
||||
"""List all webhooks for the organization."""
|
||||
return self._request("GET", "/v1/webhooks")
|
||||
|
||||
def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
|
||||
"""Register a new webhook."""
|
||||
return self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
|
||||
def register_webhook(
|
||||
self, endpoint_url: str, events: List[str], secret: str
|
||||
) -> RegisterWebhookResponse:
|
||||
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||
return self._request("POST", "/v1/webhooks", json_body=body)
|
||||
|
||||
def delete_webhook(self, webhook_id: str) -> None:
|
||||
"""Delete a webhook."""
|
||||
self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Members
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def list_members(self) -> ListMembersResponse:
|
||||
"""List all members in the organization."""
|
||||
return self._request("GET", "/v1/members")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Admin events
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||
"""List recent admin events."""
|
||||
params: Dict[str, str] = {}
|
||||
if limit is not None:
|
||||
params["limit"] = str(limit)
|
||||
return self._request("GET", "/v1/admin/events", params=params)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# JWKS
|
||||
# -----------------------------------------------------------------
|
||||
return self._request("GET", "/v1/admin/events", params=admin_events_params(limit))
|
||||
|
||||
def get_jwks(self) -> JWKSResponse:
|
||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Health
|
||||
# -----------------------------------------------------------------
|
||||
return self._get_public("/.well-known/elydora/jwks.json")
|
||||
|
||||
def health(self) -> HealthResponse:
|
||||
"""Check API health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
return self._get_public("/v1/health")
|
||||
|
||||
def deep_health(self) -> DeepHealthResponse:
|
||||
"""Check API deep health (public, no auth required)."""
|
||||
url = f"{self.base_url}/v1/health/deep"
|
||||
resp = self._session.get(url, timeout=30)
|
||||
return self._handle_response(resp)
|
||||
return self._get_public("/v1/health/deep")
|
||||
+15
-81
@@ -1,25 +1,18 @@
|
||||
"""Cryptographic utilities: Ed25519 signing, SHA-256, chain hash, JCS canonicalization.
|
||||
|
||||
Mirrors the backend implementation in ElydoraBackend/src/utils/crypto.ts exactly.
|
||||
"""
|
||||
"""Ed25519 signing, SHA-256, chain hash, and RFC 8785 canonicalization; mirrors the Backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
|
||||
from .utils import base64url_decode, base64url_encode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA-256
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||
"""Compute SHA-256 of data and return base64url-encoded hash."""
|
||||
if isinstance(data, str):
|
||||
@@ -28,39 +21,21 @@ def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||
return base64url_encode(digest)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JCS Canonicalization (RFC 8785)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _jcs_serialize_number(value: Union[int, float]) -> str:
|
||||
"""Serialize a number per JCS / ES2015 Number serialization rules."""
|
||||
"""ES2015 number serialization: NaN and infinities become null, -0.0 becomes 0."""
|
||||
if isinstance(value, bool):
|
||||
# bool is a subclass of int in Python; handle before int check
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
# float
|
||||
if math.isnan(value) or math.isinf(value):
|
||||
return "null"
|
||||
if value == 0.0:
|
||||
# Handle -0.0 -> "0"
|
||||
return "0"
|
||||
# Use Python's repr which matches ES2015 for normal floats,
|
||||
# but we need to ensure no trailing zeros beyond what's needed.
|
||||
# json.dumps handles this correctly per the JSON spec.
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def jcs_canonicalize(value: Any) -> str:
|
||||
"""Canonicalize a value according to JCS (RFC 8785).
|
||||
|
||||
- Object keys sorted lexicographically
|
||||
- No whitespace
|
||||
- Numbers serialized using ES2015 rules
|
||||
- Strings serialized with minimal JSON escaping
|
||||
- undefined values in objects are omitted
|
||||
"""
|
||||
"""Canonicalize a value according to JCS (RFC 8785)."""
|
||||
if value is None:
|
||||
return "null"
|
||||
|
||||
@@ -78,60 +53,34 @@ def jcs_canonicalize(value: Any) -> str:
|
||||
return "[" + ",".join(elements) + "]"
|
||||
|
||||
if isinstance(value, dict):
|
||||
keys = sorted(value.keys())
|
||||
pairs = []
|
||||
for key in keys:
|
||||
v = value[key]
|
||||
if v is not None or key in value:
|
||||
# Include keys with None values (maps to JSON null),
|
||||
# but skip keys that don't exist (which won't happen with dict iteration)
|
||||
pairs.append(json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(v))
|
||||
pairs = [
|
||||
json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key])
|
||||
for key in sorted(value.keys())
|
||||
]
|
||||
return "{" + ",".join(pairs) + "}"
|
||||
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_payload_hash(payload: Any) -> str:
|
||||
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
|
||||
canonical = jcs_canonicalize(payload)
|
||||
return sha256_base64url(canonical)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_chain_hash(
|
||||
prev_chain_hash: str,
|
||||
payload_hash: str,
|
||||
operation_id: str,
|
||||
issued_at: int,
|
||||
) -> str:
|
||||
"""Compute chain hash: SHA-256(prev|payload_hash|op_id|issued_at) as base64url.
|
||||
|
||||
Matches the backend formula exactly:
|
||||
chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + str(issued_at))
|
||||
"""
|
||||
"""SHA-256 of "prev|payload_hash|operation_id|issued_at" as base64url."""
|
||||
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
|
||||
return sha256_base64url(input_str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ed25519 signing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||
"""Sign data with Ed25519 private key (32-byte seed, base64url encoded).
|
||||
|
||||
Returns a base64url-encoded 64-byte signature.
|
||||
"""
|
||||
"""Sign data with a base64url 32-byte Ed25519 seed; returns a base64url signature."""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
signature = key.sign(data)
|
||||
@@ -139,29 +88,14 @@ def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||
|
||||
|
||||
def get_public_key_base64url(private_key_base64url: str) -> str:
|
||||
"""Derive the Ed25519 public key from the private key seed.
|
||||
|
||||
Returns base64url-encoded 32-byte public key.
|
||||
"""
|
||||
"""Derive the base64url Ed25519 public key from a base64url seed."""
|
||||
seed = base64url_decode(private_key_base64url)
|
||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||
pub = key.public_key()
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
raw_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return base64url_encode(raw_bytes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EOR signing
|
||||
# ---------------------------------------------------------------------------
|
||||
public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key()
|
||||
return base64url_encode(public_key.public_bytes(Encoding.Raw, PublicFormat.Raw))
|
||||
|
||||
|
||||
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
|
||||
"""Sign an EOR by canonicalizing all fields except 'signature', then signing.
|
||||
|
||||
Returns the base64url-encoded Ed25519 signature.
|
||||
"""
|
||||
# Build the signable object: all EOR fields except 'signature'
|
||||
"""Sign the JCS form of every EOR field except signature."""
|
||||
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
|
||||
canonical = jcs_canonicalize(signable)
|
||||
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
|
||||
+55
-77
@@ -1,7 +1,4 @@
|
||||
"""Type definitions matching the Elydora backend API contract.
|
||||
|
||||
All types use TypedDict for structural typing, compatible with Python 3.10+.
|
||||
"""
|
||||
"""TypedDict definitions matching the Elydora Backend API contract."""
|
||||
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -12,10 +9,6 @@ else:
|
||||
from typing_extensions import Literal, NotRequired, TypedDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums (as Literal types)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AgentStatus = Literal["active", "frozen", "revoked"]
|
||||
KeyStatus = Literal["active", "retired", "revoked"]
|
||||
ExportStatus = Literal["queued", "running", "done", "failed"]
|
||||
@@ -53,22 +46,32 @@ INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
|
||||
"enterprise", "gui", "sdk", "other",
|
||||
)
|
||||
AdminAction = Literal[
|
||||
"agent.register",
|
||||
"agent.update",
|
||||
"agent.freeze",
|
||||
"agent.unfreeze",
|
||||
"agent.revoke_key",
|
||||
"agent.revoke",
|
||||
"agent.delete",
|
||||
"key.revoke",
|
||||
"export.create",
|
||||
"org.create",
|
||||
"org.update",
|
||||
"member.invite",
|
||||
"invitation.cancel",
|
||||
"member.remove",
|
||||
"member.role_change",
|
||||
"agent.assign",
|
||||
"agent.unassign",
|
||||
"webhook.register",
|
||||
"webhook.delete",
|
||||
"org.update",
|
||||
]
|
||||
ErrorCode = Literal[
|
||||
"INVALID_SIGNATURE",
|
||||
"UNKNOWN_AGENT",
|
||||
"KEY_REVOKED",
|
||||
"KEY_RETIRED",
|
||||
"AGENT_FROZEN",
|
||||
"AGENT_REVOKED",
|
||||
"TTL_EXPIRED",
|
||||
"REPLAY_DETECTED",
|
||||
"PREV_HASH_MISMATCH",
|
||||
@@ -81,11 +84,6 @@ ErrorCode = Literal[
|
||||
"VALIDATION_ERROR",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entity types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Agent(TypedDict):
|
||||
agent_id: str
|
||||
org_id: str
|
||||
@@ -175,11 +173,6 @@ class Export(TypedDict):
|
||||
completed_at: Optional[int]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EOR(TypedDict):
|
||||
"""Elydora Operation Record."""
|
||||
op_version: Literal["1.0"]
|
||||
@@ -215,11 +208,6 @@ class EAR(TypedDict):
|
||||
elydora_signature: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API request types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentKeyParam(TypedDict):
|
||||
kid: str
|
||||
public_key: str
|
||||
@@ -234,21 +222,6 @@ class RegisterAgentRequest(TypedDict):
|
||||
responsible_entity: NotRequired[str]
|
||||
|
||||
|
||||
class AuditQueryParams(TypedDict, total=False):
|
||||
org_id: str
|
||||
agent_id: str
|
||||
operation_type: str
|
||||
start_time: int
|
||||
end_time: int
|
||||
cursor: str
|
||||
limit: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API response types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RegisterAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
keys: List[AgentKey]
|
||||
@@ -266,6 +239,7 @@ class SubmitOperationResponse(TypedDict):
|
||||
class GetOperationResponse(TypedDict, total=False):
|
||||
operation: Operation
|
||||
receipt: Receipt
|
||||
payload: Any
|
||||
|
||||
|
||||
class VerifyChecks(TypedDict, total=False):
|
||||
@@ -348,8 +322,27 @@ class DeleteAgentResponse(TypedDict):
|
||||
deleted: bool
|
||||
|
||||
|
||||
class AuthMeUser(TypedDict):
|
||||
user_id: str
|
||||
org_id: Optional[str]
|
||||
email: str
|
||||
display_name: str
|
||||
role: RbacRole
|
||||
status: Literal["active", "suspended"]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
onboarding_completed: bool
|
||||
|
||||
|
||||
class CurrentOrganization(TypedDict):
|
||||
org_id: Optional[str]
|
||||
ba_org_id: Optional[str]
|
||||
role: RbacRole
|
||||
|
||||
|
||||
class GetMeResponse(TypedDict):
|
||||
user: User
|
||||
user: AuthMeUser
|
||||
current_organization: CurrentOrganization
|
||||
|
||||
|
||||
class IssueApiTokenResponse(TypedDict):
|
||||
@@ -364,39 +357,30 @@ class RotateApiTokenResponse(TypedDict):
|
||||
token_id: str
|
||||
|
||||
|
||||
# Backward compatibility aliases (deprecated)
|
||||
IssueTokenResponse = IssueApiTokenResponse
|
||||
RotateTokenResponse = RotateApiTokenResponse
|
||||
|
||||
|
||||
class HealthResponse(TypedDict):
|
||||
status: str
|
||||
version: str
|
||||
protocol_version: str
|
||||
capabilities: Dict[str, str]
|
||||
timestamp: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional entity types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AdminEvent(TypedDict):
|
||||
event_id: str
|
||||
org_id: str
|
||||
actor_user_id: str
|
||||
actor: str
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
metadata: Optional[Dict[str, Any]]
|
||||
details: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
class AgentAssignment(TypedDict):
|
||||
assignment_id: str
|
||||
id: str
|
||||
agent_id: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
assigned_to: str
|
||||
assigned_by: str
|
||||
created_at: int
|
||||
|
||||
@@ -406,48 +390,36 @@ class WebhookEntry(TypedDict):
|
||||
org_id: str
|
||||
endpoint_url: str
|
||||
events: List[str]
|
||||
status: Literal["active", "disabled"]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class MemberEntry(TypedDict):
|
||||
member_id: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
role: str
|
||||
joined_at: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: RbacRole
|
||||
status: Literal["active", "suspended"]
|
||||
created_at: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional request types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UpdateAgentRequest(TypedDict):
|
||||
integration_type: IntegrationType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional response types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UpdateAgentResponse(TypedDict):
|
||||
agent: Agent
|
||||
|
||||
|
||||
class FreezeAgentResponse(TypedDict):
|
||||
agent_id: str
|
||||
agent: Agent
|
||||
previous_status: AgentStatus
|
||||
status: AgentStatus
|
||||
|
||||
|
||||
class UnfreezeAgentResponse(TypedDict):
|
||||
agent_id: str
|
||||
agent: Agent
|
||||
previous_status: AgentStatus
|
||||
status: AgentStatus
|
||||
|
||||
|
||||
class ListWebhooksResponse(TypedDict):
|
||||
@@ -464,12 +436,18 @@ class ListMembersResponse(TypedDict):
|
||||
|
||||
class ListAdminEventsResponse(TypedDict):
|
||||
events: List[AdminEvent]
|
||||
cursor: Optional[str]
|
||||
|
||||
|
||||
class DeepHealthResponse(TypedDict, total=False):
|
||||
status: str
|
||||
class DependencyHealth(TypedDict):
|
||||
status: Literal["ok", "failed"]
|
||||
latency_ms: int
|
||||
contract_phase: NotRequired[Literal["expand", "contract"]]
|
||||
|
||||
|
||||
class DeepHealthResponse(TypedDict):
|
||||
status: Literal["healthy", "degraded"]
|
||||
version: str
|
||||
protocol_version: str
|
||||
capabilities: Dict[str, str]
|
||||
timestamp: int
|
||||
checks: Dict[str, Any]
|
||||
dependencies: Dict[str, DependencyHealth]
|
||||
+4
-19
@@ -28,27 +28,12 @@ def generate_nonce() -> str:
|
||||
|
||||
|
||||
def generate_uuidv7() -> str:
|
||||
"""Generate a UUIDv7 (time-ordered, random) as a string.
|
||||
|
||||
Layout (RFC 9562):
|
||||
- 48-bit Unix timestamp in milliseconds
|
||||
- 4-bit version (0b0111)
|
||||
- 12-bit random
|
||||
- 2-bit variant (0b10)
|
||||
- 62-bit random
|
||||
"""
|
||||
"""UUIDv7 (RFC 9562): 48-bit ms timestamp, version 7, variant 10, 74 random bits."""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
|
||||
# 48-bit timestamp
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:] # last 6 bytes of 8-byte big-endian
|
||||
|
||||
# 2 bytes: version (4 bits = 0x7) + 12 random bits
|
||||
rand_a = struct.unpack(">H", os.urandom(2))[0]
|
||||
rand_a = (rand_a & 0x0FFF) | 0x7000 # set version nibble
|
||||
|
||||
# 8 bytes: variant (2 bits = 0b10) + 62 random bits
|
||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
|
||||
rand_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000
|
||||
rand_b = bytearray(os.urandom(8))
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80 # set variant bits
|
||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
|
||||
|
||||
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_sync_client_does_not_replay_non_idempotent_requests(
|
||||
)
|
||||
|
||||
with pytest.raises(ElydoraError) as error:
|
||||
client.issue_token()
|
||||
client.issue_api_token()
|
||||
assert error.value.status_code == 503
|
||||
assert scripted_server.methods == ["POST"]
|
||||
assert delays == []
|
||||
@@ -241,7 +241,7 @@ async def test_async_client_does_not_replay_non_idempotent_requests(
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ElydoraError) as error:
|
||||
await client.issue_token()
|
||||
await client.issue_api_token()
|
||||
assert error.value.status_code == 503
|
||||
assert scripted_server.methods == ["POST"]
|
||||
assert delays == []
|
||||
|
||||
Reference in new issue
Block a user