diff --git a/README.md b/README.md index 9dd068e..e44d846 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/elydora/__init__.py b/elydora/__init__.py index c66352e..aff3bb5 100644 --- a/elydora/__init__.py +++ b/elydora/__init__.py @@ -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", ] diff --git a/elydora/_client_common.py b/elydora/_client_common.py new file mode 100644 index 0000000..b26e400 --- /dev/null +++ b/elydora/_client_common.py @@ -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] diff --git a/elydora/async_client.py b/elydora/async_client.py index 2dd525c..958f303 100644 --- a/elydora/async_client.py +++ b/elydora/async_client.py @@ -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") diff --git a/elydora/cli.py b/elydora/cli.py index 8176599..b9bf4da 100644 --- a/elydora/cli.py +++ b/elydora/cli.py @@ -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() diff --git a/elydora/client.py b/elydora/client.py index 1eb6b4b..d8f8f2e 100644 --- a/elydora/client.py +++ b/elydora/client.py @@ -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") diff --git a/elydora/crypto.py b/elydora/crypto.py index aabd5e5..986e2b4 100644 --- a/elydora/crypto.py +++ b/elydora/crypto.py @@ -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")) diff --git a/elydora/types.py b/elydora/types.py index 8dc359c..a163686 100644 --- a/elydora/types.py +++ b/elydora/types.py @@ -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] diff --git a/elydora/utils.py b/elydora/utils.py index ba90e41..9707a27 100644 --- a/elydora/utils.py +++ b/elydora/utils.py @@ -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) diff --git a/tests/test_retry_contract.py b/tests/test_retry_contract.py index 2a1c35e..ef33df1 100644 --- a/tests/test_retry_contract.py +++ b/tests/test_retry_contract.py @@ -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 == []