From eef18a85208c3d7aa3c0e5244e6e31e8c7108eb6 Mon Sep 17 00:00:00 2001 From: Zachary Date: Sun, 1 Mar 2026 15:53:53 -0500 Subject: [PATCH] Fix async genesis hash bug, add 7 missing methods, document exports - Fix AsyncElydoraClient _prev_chain_hash from empty string to GENESIS_CHAIN_HASH - Add list_agents, unfreeze_agent, delete_agent, get_me, issue_token, download_export, health - Add methods to both sync and async clients - Add corresponding response types to types.py - Export new types from __init__.py - Document all previously undocumented exports in README Co-Authored-By: Claude Opus 4.6 --- README.md | 79 +++++++++++++++++++++++++++++++++++++++++ elydora/__init__.py | 10 ++++++ elydora/async_client.py | 56 ++++++++++++++++++++++++++++- elydora/client.py | 46 ++++++++++++++++++++++++ elydora/types.py | 24 +++++++++++++ 5 files changed, 214 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab9ef5e..c1b5fe9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,12 @@ reg = ElydoraClient.register(base_url, email, password, display_name=None, org_n # Login and receive a JWT auth = ElydoraClient.login(base_url, email, password) + +# Get current authenticated user profile +me = client.get_me() + +# Issue a new API token (with optional TTL in seconds) +token_resp = client.issue_token(ttl_seconds=3600) ``` ### Operations @@ -159,6 +165,15 @@ client.freeze_agent(agent_id, reason="security review") # Revoke a key client.revoke_key(agent_id, kid, reason="key rotation") + +# List all agents in the organization +agents_resp = client.list_agents() + +# Unfreeze a previously frozen agent +client.unfreeze_agent(agent_id, reason="review complete") + +# Delete an agent permanently +deleted_resp = client.delete_agent(agent_id) ``` ### Audit @@ -193,6 +208,9 @@ export = client.create_export( exports = client.list_exports() detail = client.get_export(export_id) + +# Download export file data +data = client.download_export(export_id) ``` ### JWKS @@ -201,6 +219,67 @@ detail = client.get_export(export_id) jwks = client.get_jwks() ``` +### Health + +```python +# Check API health (no authentication required) +health = client.health() +# health["status"], health["version"], health["protocol_version"], health["timestamp"] +``` + +### Crypto Functions + +The SDK exports low-level cryptographic primitives for advanced use: + +```python +from elydora import ( + jcs_canonicalize, # RFC 8785 JSON Canonicalization + sha256_base64url, # SHA-256 hash as base64url + compute_chain_hash, # Chain hash computation + compute_payload_hash, # Payload hash (SHA-256 of JCS-canonicalized payload) + sign_ed25519, # Ed25519 signing + sign_eor, # Sign an EOR dict + get_public_key_base64url, # Derive public key from private seed +) +``` + +### Utility Functions + +```python +from elydora import ( + base64url_encode, # Encode bytes to base64url (no padding) + base64url_decode, # Decode base64url string to bytes + generate_nonce, # Generate 16-byte random nonce (base64url) + generate_uuidv7, # Generate UUIDv7 (time-ordered, RFC 9562) +) +``` + +### Type Definitions + +All types are `TypedDict` classes for structural typing: + +```python +from elydora import ( + # Protocol types + EOR, # Elydora Operation Record + EAR, # Elydora Acknowledgment Receipt + + # Entity types + Agent, AgentKey, Operation, Receipt, Epoch, Export, Organization, User, + + # API response types + RegisterAgentResponse, GetAgentResponse, ListAgentsResponse, + DeleteAgentResponse, SubmitOperationResponse, GetOperationResponse, + VerifyOperationResponse, AuditQueryResponse, GetEpochResponse, + ListEpochsResponse, CreateExportResponse, GetExportResponse, + ListExportsResponse, JWKSResponse, AuthRegisterResponse, + AuthLoginResponse, GetMeResponse, IssueTokenResponse, HealthResponse, + + # Request types + RegisterAgentRequest, +) +``` + ## Error Handling ```python diff --git a/elydora/__init__.py b/elydora/__init__.py index 3b715fa..28577fe 100644 --- a/elydora/__init__.py +++ b/elydora/__init__.py @@ -27,6 +27,8 @@ from .types import ( RegisterAgentRequest, RegisterAgentResponse, GetAgentResponse, + ListAgentsResponse, + DeleteAgentResponse, SubmitOperationResponse, GetOperationResponse, VerifyOperationResponse, @@ -39,6 +41,9 @@ from .types import ( JWKSResponse, AuthRegisterResponse, AuthLoginResponse, + GetMeResponse, + IssueTokenResponse, + HealthResponse, ) __version__ = "1.0.0" @@ -71,6 +76,8 @@ __all__ = [ "RegisterAgentRequest", "RegisterAgentResponse", "GetAgentResponse", + "ListAgentsResponse", + "DeleteAgentResponse", "SubmitOperationResponse", "GetOperationResponse", "VerifyOperationResponse", @@ -83,4 +90,7 @@ __all__ = [ "JWKSResponse", "AuthRegisterResponse", "AuthLoginResponse", + "GetMeResponse", + "IssueTokenResponse", + "HealthResponse", ] diff --git a/elydora/async_client.py b/elydora/async_client.py index 7bf4e4b..ff741b4 100644 --- a/elydora/async_client.py +++ b/elydora/async_client.py @@ -15,12 +15,17 @@ from .types import ( AuthLoginResponse, AuthRegisterResponse, CreateExportResponse, + DeleteAgentResponse, EOR, GetAgentResponse, GetEpochResponse, GetExportResponse, + GetMeResponse, GetOperationResponse, + HealthResponse, + IssueTokenResponse, JWKSResponse, + ListAgentsResponse, ListEpochsResponse, ListExportsResponse, RegisterAgentRequest, @@ -30,6 +35,10 @@ from .types import ( ) 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 AsyncElydoraClient: """Asynchronous client for the Elydora API. @@ -63,7 +72,7 @@ class AsyncElydoraClient: self.max_retries = max_retries self.token = token - self._prev_chain_hash = "" + self._prev_chain_hash = GENESIS_CHAIN_HASH self._kid = "" self._session: Optional[aiohttp.ClientSession] = None @@ -186,6 +195,17 @@ class AsyncElydoraClient: ) as resp: return await AsyncElydoraClient._handle_response(resp) + async def get_me(self) -> GetMeResponse: + """Get the current user's profile.""" + return await self._request("GET", "/v1/auth/me") + + async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse: + """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) + # ----------------------------------------------------------------- # Agent management # ----------------------------------------------------------------- @@ -202,6 +222,18 @@ class AsyncElydoraClient: """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) -> None: + """Unfreeze an agent.""" + await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}) + + 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( @@ -347,6 +379,17 @@ class AsyncElydoraClient: """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() + # ----------------------------------------------------------------- # JWKS # ----------------------------------------------------------------- @@ -357,3 +400,14 @@ class AsyncElydoraClient: 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: + """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) diff --git a/elydora/client.py b/elydora/client.py index ceb92d8..292544f 100644 --- a/elydora/client.py +++ b/elydora/client.py @@ -14,12 +14,17 @@ from .types import ( AuthLoginResponse, AuthRegisterResponse, CreateExportResponse, + DeleteAgentResponse, EOR, GetAgentResponse, GetEpochResponse, GetExportResponse, + GetMeResponse, GetOperationResponse, + HealthResponse, + IssueTokenResponse, JWKSResponse, + ListAgentsResponse, ListEpochsResponse, ListExportsResponse, RegisterAgentRequest, @@ -181,6 +186,17 @@ class ElydoraClient: resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30) return ElydoraClient._handle_response(resp) + def get_me(self) -> GetMeResponse: + """Get the current user's profile.""" + return self._request("GET", "/v1/auth/me") + + def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueTokenResponse: + """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) + # ----------------------------------------------------------------- # Agent management # ----------------------------------------------------------------- @@ -197,6 +213,18 @@ class ElydoraClient: """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 unfreeze_agent(self, agent_id: str, reason: str) -> None: + """Unfreeze an agent.""" + self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}) + + 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( @@ -344,6 +372,14 @@ class ElydoraClient: """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 + # ----------------------------------------------------------------- # JWKS # ----------------------------------------------------------------- @@ -353,3 +389,13 @@ class ElydoraClient: 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: + """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) diff --git a/elydora/types.py b/elydora/types.py index b3cd065..1877b9d 100644 --- a/elydora/types.py +++ b/elydora/types.py @@ -291,3 +291,27 @@ class AuthRegisterResponse(TypedDict): class AuthLoginResponse(TypedDict): user: User token: str + + +class ListAgentsResponse(TypedDict): + agents: List[Agent] + + +class DeleteAgentResponse(TypedDict): + deleted: bool + + +class GetMeResponse(TypedDict): + user: User + + +class IssueTokenResponse(TypedDict): + token: str + expires_at: Optional[int] + + +class HealthResponse(TypedDict): + status: str + version: str + protocol_version: str + timestamp: int