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 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-01 15:53:53 -05:00
1 parent 3c77b4a31d
commit eef18a8520
5 files changed
+214 -1

No files matched your search

+79
View File
@@ -117,6 +117,12 @@ reg = ElydoraClient.register(base_url, email, password, display_name=None, org_n
# Login and receive a JWT # Login and receive a JWT
auth = ElydoraClient.login(base_url, email, password) 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 ### Operations
@@ -159,6 +165,15 @@ client.freeze_agent(agent_id, reason="security review")
# Revoke a key # Revoke a key
client.revoke_key(agent_id, kid, reason="key rotation") 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 ### Audit
@@ -193,6 +208,9 @@ export = client.create_export(
exports = client.list_exports() exports = client.list_exports()
detail = client.get_export(export_id) detail = client.get_export(export_id)
# Download export file data
data = client.download_export(export_id)
``` ```
### JWKS ### JWKS
@@ -201,6 +219,67 @@ detail = client.get_export(export_id)
jwks = client.get_jwks() 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 ## Error Handling
```python ```python
+10
View File
@@ -27,6 +27,8 @@ from .types import (
RegisterAgentRequest, RegisterAgentRequest,
RegisterAgentResponse, RegisterAgentResponse,
GetAgentResponse, GetAgentResponse,
ListAgentsResponse,
DeleteAgentResponse,
SubmitOperationResponse, SubmitOperationResponse,
GetOperationResponse, GetOperationResponse,
VerifyOperationResponse, VerifyOperationResponse,
@@ -39,6 +41,9 @@ from .types import (
JWKSResponse, JWKSResponse,
AuthRegisterResponse, AuthRegisterResponse,
AuthLoginResponse, AuthLoginResponse,
GetMeResponse,
IssueTokenResponse,
HealthResponse,
) )
__version__ = "1.0.0" __version__ = "1.0.0"
@@ -71,6 +76,8 @@ __all__ = [
"RegisterAgentRequest", "RegisterAgentRequest",
"RegisterAgentResponse", "RegisterAgentResponse",
"GetAgentResponse", "GetAgentResponse",
"ListAgentsResponse",
"DeleteAgentResponse",
"SubmitOperationResponse", "SubmitOperationResponse",
"GetOperationResponse", "GetOperationResponse",
"VerifyOperationResponse", "VerifyOperationResponse",
@@ -83,4 +90,7 @@ __all__ = [
"JWKSResponse", "JWKSResponse",
"AuthRegisterResponse", "AuthRegisterResponse",
"AuthLoginResponse", "AuthLoginResponse",
"GetMeResponse",
"IssueTokenResponse",
"HealthResponse",
] ]
+55 -1
View File
@@ -15,12 +15,17 @@ from .types import (
AuthLoginResponse, AuthLoginResponse,
AuthRegisterResponse, AuthRegisterResponse,
CreateExportResponse, CreateExportResponse,
DeleteAgentResponse,
EOR, EOR,
GetAgentResponse, GetAgentResponse,
GetEpochResponse, GetEpochResponse,
GetExportResponse, GetExportResponse,
GetMeResponse,
GetOperationResponse, GetOperationResponse,
HealthResponse,
IssueTokenResponse,
JWKSResponse, JWKSResponse,
ListAgentsResponse,
ListEpochsResponse, ListEpochsResponse,
ListExportsResponse, ListExportsResponse,
RegisterAgentRequest, RegisterAgentRequest,
@@ -30,6 +35,10 @@ from .types import (
) )
from .utils import generate_nonce, generate_uuidv7 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: class AsyncElydoraClient:
"""Asynchronous client for the Elydora API. """Asynchronous client for the Elydora API.
@@ -63,7 +72,7 @@ class AsyncElydoraClient:
self.max_retries = max_retries self.max_retries = max_retries
self.token = token self.token = token
self._prev_chain_hash = "" self._prev_chain_hash = GENESIS_CHAIN_HASH
self._kid = "" self._kid = ""
self._session: Optional[aiohttp.ClientSession] = None self._session: Optional[aiohttp.ClientSession] = None
@@ -186,6 +195,17 @@ class AsyncElydoraClient:
) as resp: ) as resp:
return await AsyncElydoraClient._handle_response(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 # Agent management
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@@ -202,6 +222,18 @@ class AsyncElydoraClient:
"""Freeze an agent.""" """Freeze an agent."""
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}) 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: async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key.""" """Revoke an agent's key."""
await self._request( await self._request(
@@ -347,6 +379,17 @@ class AsyncElydoraClient:
"""Retrieve export status and download URL.""" """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:
"""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 # JWKS
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@@ -357,3 +400,14 @@ class AsyncElydoraClient:
session = await self._get_session() session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(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)
+46
View File
@@ -14,12 +14,17 @@ from .types import (
AuthLoginResponse, AuthLoginResponse,
AuthRegisterResponse, AuthRegisterResponse,
CreateExportResponse, CreateExportResponse,
DeleteAgentResponse,
EOR, EOR,
GetAgentResponse, GetAgentResponse,
GetEpochResponse, GetEpochResponse,
GetExportResponse, GetExportResponse,
GetMeResponse,
GetOperationResponse, GetOperationResponse,
HealthResponse,
IssueTokenResponse,
JWKSResponse, JWKSResponse,
ListAgentsResponse,
ListEpochsResponse, ListEpochsResponse,
ListExportsResponse, ListExportsResponse,
RegisterAgentRequest, RegisterAgentRequest,
@@ -181,6 +186,17 @@ class ElydoraClient:
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30) resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
return ElydoraClient._handle_response(resp) 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 # Agent management
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@@ -197,6 +213,18 @@ class ElydoraClient:
"""Freeze an agent.""" """Freeze an agent."""
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}) 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: def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key.""" """Revoke an agent's key."""
self._request( self._request(
@@ -344,6 +372,14 @@ class ElydoraClient:
"""Retrieve export status and download URL.""" """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:
"""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 # JWKS
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@@ -353,3 +389,13 @@ class ElydoraClient:
url = f"{self.base_url}/.well-known/elydora/jwks.json" url = f"{self.base_url}/.well-known/elydora/jwks.json"
resp = self._session.get(url, timeout=30) resp = self._session.get(url, timeout=30)
return self._handle_response(resp) 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)
+24
View File
@@ -291,3 +291,27 @@ class AuthRegisterResponse(TypedDict):
class AuthLoginResponse(TypedDict): class AuthLoginResponse(TypedDict):
user: User user: User
token: str 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