aere-cloud-sdk/python/aere_cloud/__init__.py
Liviu af565d3aec Install from this repository (the clients are not on npm or PyPI yet); Python and Java clients 1.5.1/1.5.0 added
The README told readers to run npm install @aere/cloud and pip install aere-cloud; neither name exists on those registries, so the
command failed, and a name there that looks like ours is not ours. Install now points at this repository for JavaScript, Python
(python/, subdirectory install) and Go (go/); java/ builds from source with the JDK alone.
2026-09-25 11:02:13 +03:00

257 lines
12 KiB
Python

"""aere-cloud: clientul Python oficial al Aere Cloud (chain 2800).
Oglinda fidela a @aere/cloud (sdk/index.mjs): fiecare ruta a gateway-ului e o metoda,
erorile se ridica drept AereCloudError (cod HTTP + corpul serverului), si verify_webhook
e inclus ca primitorul de webhook-uri sa nu-si scrie singur verificarea HMAC. Zero
dependinte in afara bibliotecii standard (urllib, hmac, hashlib, json); Python 3.9+.
"""
from __future__ import annotations
import hmac
import hashlib
import json as _json
import urllib.request
import urllib.error
from typing import Any, Optional
__version__ = "1.5.1"
__all__ = ["AereCloud", "AereCloudError", "verify_webhook"]
_BAZA = "https://cloud.aere.network/v1"
class _FaraRedirectari(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_FARA_REDIRECTARI = urllib.request.build_opener(_FaraRedirectari)
class AereCloudError(Exception):
"""Raspuns non-2xx (sau eroare JSON-RPC) de la Aere Cloud.
Poarta .status (int), .body (dict sau None, corpul serverului) si .path, ca
integratorul sa poata actiona pe eroarea exacta, nu pe un mesaj generic.
"""
def __init__(self, status: int, body: Any, path: str):
mesaj = body.get("error") if isinstance(body, dict) else None
super().__init__(f"Aere Cloud {status} on {path}: {mesaj or 'request failed'}")
self.status = status
self.body = body
self.path = path
class AereCloud:
"""Client pentru https://cloud.aere.network/v1. Cheia: ak2800.<adresa>.<secret>."""
def __init__(self, api_key: str, base_url: str = _BAZA, timeout: float = 30.0, opener=None):
if not api_key:
raise ValueError("AereCloud: api_key is required (ak2800.<address>.<secret>)")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.timeout = timeout
# opener injectabil pentru teste: primeste (urllib.request.Request, timeout) si
# intoarce (status:int, corp:bytes). Implicit, urllib.
self._opener = opener or self._urllib_opener
@staticmethod
def _urllib_opener(cerere: urllib.request.Request, timeout: float):
try:
# 1.5.1: fara redirectari urmate (urllib le urmeaza implicit si copiaza x-api-key spre orice gazda din Location);
# un 3xx ajunge aici ca HTTPError si se intoarce drept raspuns, deci ca AereCloudError cu statusul lui
with _FARA_REDIRECTARI.open(cerere, timeout=timeout) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def _req(self, metoda: str, cale: str, body: Any = None, auth: bool = True) -> Any:
# User-Agent-ul explicit nu e cosmetic: marginea (Cloudflare) raspunde 403 la
# "Python-urllib/3.x" pentru ORICE ruta, inclusiv /health fara cheie. Masurat
# 2026-08-27 chiar la prima rulare vie a acestui SDK.
anteturi = {"content-type": "application/json", "user-agent": f"aere-cloud-python/{__version__}"}
if auth:
anteturi["x-api-key"] = self.api_key
date = None if body is None else _json.dumps(body).encode("utf-8")
cerere = urllib.request.Request(self.base_url + cale, data=date, headers=anteturi, method=metoda)
status, corp = self._opener(cerere, self.timeout)
try:
d = _json.loads(corp.decode("utf-8")) if corp else None
except ValueError:
d = None
if not (200 <= status < 300):
raise AereCloudError(status, d, cale)
return d
# ---- health & account ----
def health(self) -> dict:
return self._req("GET", "/health", auth=False)
def account(self) -> dict:
return self._req("GET", "/account")
# ---- JSON-RPC ----
def rpc(self, method: str, params: Optional[list] = None) -> Any:
d = self._req("POST", "/rpc", {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []})
if isinstance(d, dict) and d.get("error"):
raise AereCloudError(200, d, "/rpc")
return d.get("result") if isinstance(d, dict) else None
def block_number(self) -> int:
return int(self.rpc("eth_blockNumber"), 16)
# ---- post-quantum verification ----
def pq_verify(self, payload: dict) -> dict:
"""{scheme, publicKey, signature?, message?, signedMessage?} (0x-hex) -> {valid, ...}"""
return self._req("POST", "/pq/verify", payload)
# ---- chain data ----
def data_head(self) -> dict:
return self._req("GET", "/data/head")
def validators(self) -> dict:
return self._req("GET", "/data/validators")
def anchors(self, limit: int = 10) -> dict:
return self._req("GET", f"/data/anchors?limit={int(limit)}")
def anchor(self, height: int) -> dict:
return self._req("GET", f"/data/anchors/{int(height)}")
def transfers(self, address: str, limit: int = 50) -> dict:
return self._req("GET", f"/data/transfers?address={address}&limit={int(limit)}")
# ---- notarization ----
def notarize(self, digest_hex: str) -> dict:
"""Notarizeaza un digest de 32 de octeti (0x + 64 hex); Aere plateste gazul."""
return self._req("POST", "/notarize", {"hash": digest_hex})
def proof_of(self, digest_hex: str) -> dict:
return self._req("GET", f"/notarize/{digest_hex}")
def proof(self, digest_hex: str) -> dict:
"""2026-09-17: dovada cu finalitatea ei post-cuantica (prima aparitie, ancora care o acopera, verdict, pasii de verificare)."""
return self._req("GET", f"/proof/{digest_hex}")
# ---- quantum readiness ----
def readiness(self, domain: str, attest: bool = False, fresh: bool = False) -> dict:
"""Scanarea marginii TLS publice a unui domeniu: libera, fara cheie; attest=True notarizeaza digestul raportului pe lant (cere cheia)."""
body: dict = {"domain": domain}
if fresh:
body["fresh"] = True
if attest:
body["attest"] = True
return self._req("POST", "/pq/readiness", body, auth=attest)
def readiness_report(self, domain: str) -> dict:
return self._req("GET", f"/pq/readiness/{domain}", auth=False)
def readiness_perimeter(self, domain: str, hosts: list | None = None, discover: bool = True, attest: bool = False) -> dict:
"""Tot perimetrul public al unui domeniu intr-un raport (cu cheie): gazdele numite (etichete sau nume de sub domeniu) plus
prefixele obisnuite gasite prin DNS, fiecare masurata cu aceleasi strangeri de mana. Cat summary['complete'] e False se cere
din nou (scanarile terminate stau in cache); attest=True notarizeaza digestul unui raport COMPLET pe lantul 2800."""
body: dict = {"domain": domain}
if hosts:
body["hosts"] = list(hosts)
if discover is False:
body["discover"] = False
if attest:
body["attest"] = True
return self._req("POST", "/pq/readiness/perimeter", body)
def pq_finality(self, block: str = "latest", network: str = "testnet", client: str = "besu") -> dict:
"""AIP-21: finalitatea post-cuantica a unui bloc (sigiliile auzite de un validator, re-verificate, verdict de cvorum). Libera.
client: "besu" (implicit), "nethermind" sau "both" (doua implementari independente, cu `agreement` calculat de gateway)."""
return self._req("GET", f"/pq/finality/{block}?network={network}&client={client}", auth=False)
# ---- audit log ----
def audit_log(self, day: Optional[str] = None, offset: int = 0, limit: int = 1000) -> dict:
"""Jurnalul cererilor cu cheie ale contului pentru o zi UTC (paginat), cu sha256-ul fisierului zilei."""
q = f"offset={int(offset)}&limit={int(limit)}" + (f"&day={day}" if day else "")
return self._req("GET", f"/account/audit?{q}")
def audit_export(self, day: Optional[str] = None, fmt: str = "jsonl") -> dict:
"""Ziua ca FISIER (1.4.0): fmt='jsonl' = octetii exacti ai fisierului zilei, al caror sha256 e digestul pe care il notarizezi
(calculat AICI, din ce ai primit, nu luat pe cuvant); fmt='cef' = un rand ArcSight CEF pe cerere, pentru un SIEM."""
import hashlib
q = f"format={fmt}" + (f"&day={day}" if day else "")
anteturi = {"user-agent": f"aere-cloud-python/{__version__}", "x-api-key": self.api_key}
cerere = urllib.request.Request(self.base_url + f"/account/audit?{q}", headers=anteturi, method="GET")
status, corp = self._opener(cerere, self.timeout)
if not (200 <= status < 300):
try:
d = _json.loads(corp.decode("utf-8")) if corp else None
except ValueError:
d = None
raise AereCloudError(status, d, "/account/audit")
corp = corp or b""
return {"format": fmt, "bytes": corp, "text": corp.decode("utf-8"), "entries": sum(1 for l in corp.split(b"\n") if l),
"sha256": "0x" + hashlib.sha256(corp).hexdigest()}
def audit_retention(self) -> dict:
"""Retentia jurnalului de audit (1.5.0), DOAR-CITIRE cu cheia API: ce e in vigoare, ce asteapta si pana cand, ce tinem, si
registrul fiecarei schimbari si al fiecarei zile sterse (cu digestul ei). Se SETEAZA cu semnatura portofelului, in consola."""
return self._req("GET", "/account/audit/retention")
def audit_verify_kept(self, day: str, data) -> dict:
"""Exportul pe care l-ai PASTRAT se mai potriveste cu ziua de la noi, chiar dupa ce am sters-o prin retentia ta? sha256 al
octetilor TAI, calculat aici, fata de digestul zilei tinute ('held') sau de piatra lasata de ziua stearsa ('expired', 410)."""
import hashlib
octeti = data.encode("utf-8") if isinstance(data, str) else bytes(data)
local = "0x" + hashlib.sha256(octeti).hexdigest()
anteturi = {"user-agent": f"aere-cloud-python/{__version__}", "x-api-key": self.api_key}
cerere = urllib.request.Request(self.base_url + f"/account/audit?format=jsonl&day={day}", headers=anteturi, method="GET")
status, corp = self._opener(cerere, self.timeout)
corp = corp or b""
if not (200 <= status < 300):
try:
d = _json.loads(corp.decode("utf-8")) if corp else None
except ValueError:
d = None
if status == 410 and isinstance(d, dict) and isinstance(d.get("expired"), dict):
e = d["expired"]
return {"day": day, "state": "expired", "digest": e.get("digest"), "local": local, "matches": local == e.get("digest"), "deleted_at": e.get("deletedAt")}
raise AereCloudError(status, d, "/account/audit")
digest = "0x" + hashlib.sha256(corp).hexdigest()
return {"day": day, "state": "held", "digest": digest, "local": local, "matches": local == digest, "deleted_at": None}
# ---- webhooks ----
def list_webhooks(self) -> dict:
return self._req("GET", "/webhooks")
def create_webhook(self, spec: dict) -> dict:
return self._req("POST", "/webhooks", spec)
def delete_webhook(self, webhook_id: str) -> dict:
return self._req("DELETE", f"/webhooks/{webhook_id}")
# ---- gas sponsorship ----
def sponsor_health(self) -> dict:
return self._req("GET", "/sponsor/health")
def sponsor_create_account(self, body: dict) -> dict:
return self._req("POST", "/sponsor/createAccount", body)
def sponsor_create_pq_account(self, falcon_pub_key: str, salt: int = 0) -> dict:
"""Cont inteligent cu proprietar POST-CUANTIC (Falcon-512), gazul platit de releu.
falcon_pub_key: 0x-hex, 897 octeti, antetul 0x09 (cheia publica Falcon-512).
Idempotent pe (cheie, salt): a doua chemare intoarce contul existent, fara tx.
"""
return self._req("POST", "/sponsor/createPqAccount", {"falconPubKey": falcon_pub_key, "salt": salt})
def sponsor_execute(self, body: dict) -> dict:
return self._req("POST", "/sponsor/execute", body)
def verify_webhook(raw_body, signature_header: str, secret: str) -> bool:
"""Verifica semnatura unei livrari de webhook. A NU crede un webhook fara asta.
raw_body: octetii EXACTI ai cererii (nu re-serializati). Comparatia e in timp constant.
"""
if isinstance(raw_body, str):
raw_body = raw_body.encode("utf-8")
asteptat = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(asteptat, str(signature_header or ""))