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:
雷电芽衣 committed 2026-09-02 23:05:37 -04:00
1 parent 54e65eb56a
commit 0f72a5e2f1
10 files changed
+414 -706

No files matched your search

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