Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,26 @@ beo = BEOClient(config)
| `is_available` | `(domain: str) -> bool` | Check if a domain is unclaimed |
| `lock` | `(reason?) -> dict` | Lock a BEO against new writes |
| `unlock` | `() -> dict` | Unlock a previously locked BEO |
| `list_beos` | `(limit=20, offset=0) -> list` | List BEOs accessible to the configured IEO |
| `destroy` | `(beo_id: str \| int, reason?) -> dict` | Permanently destroy a BEO (LGPD/GDPR erasure) |
| `update_recovery` | `(config: RecoveryConfig) -> dict` | Update guardian recovery settings |

```python
# List BEOs accessible to the configured IEO
beos = beo_client.list_beos(limit=50, offset=0)
for b in beos:
print(b["beo_id"], b["domain"])

# Permanently destroy a BEO (LGPD Art. 18 / GDPR Art. 17 — right to erasure)
# WARNING: irreversible. Nullifies key, revokes all ConsentTokens, releases domain.
result = beo_client.destroy(
beo_id=42, # accepts int or decimal string
reason="user_requested_deletion",
)
print(result["destroyed_at"])
print(result["aptos_tx"])
```

```python
# Create with guardians
from bsp_sdk import Guardian, RecoveryConfig
Expand Down
15 changes: 15 additions & 0 deletions bsp_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
)
"""

import importlib.metadata, warnings

try:
_ecdsa_ver = tuple(int(x) for x in importlib.metadata.version("ecdsa").split(".")[:2])
if _ecdsa_ver < (0, 19):
warnings.warn(
"ecdsa < 0.19 is vulnerable to CVE-2024-23342. Run: pip install 'ecdsa>=0.19'",
RuntimeWarning,
stacklevel=1,
)
except importlib.metadata.PackageNotFoundError:
pass

from .client import BSPClient
from .beo import BEOClient
from .ieo import IEOBuilder
Expand Down Expand Up @@ -45,6 +58,8 @@
__version__ = "2.1.0"

__all__ = [
# Package metadata
"__version__",
# Clients
"BSPClient", "BEOClient", "IEOBuilder",
"BioRecordBuilder", "TaxonomyResolver",
Expand Down
28 changes: 28 additions & 0 deletions bsp_sdk/beo.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,34 @@ def update_recovery(self, config: RecoveryConfig) -> dict:
raise ValueError(f"threshold must be between 1 and {len(config.guardians)}")
raise NotImplementedError("Registry connection required")

def list_beos(self, limit: int = 20, offset: int = 0) -> list:
"""List BEOs accessible to the configured IEO.

Makes a GET request to ``/api/beo?limit=N&offset=N`` and returns the
list of BEO objects from the response.

:param limit: Maximum number of BEOs to return (default: 20).
:param offset: Number of records to skip for pagination (default: 0).
:returns: List of BEO dicts as returned by the registry API.
:raises BSPApiError: Non-2xx response from the registry API.

Example::

client = BSPClient(ieo_domain="lab.bsp", private_key="...", environment="mainnet")
beos = client.beo.list_beos(limit=50, offset=0)
for beo in beos:
print(beo["beo_id"], beo["domain"])
"""
data = self.http.get(f"/api/beo?limit={limit}&offset={offset}")
if isinstance(data, list):
return data
# Some registry responses wrap the list in a 'beos' or 'items' key
if isinstance(data, dict):
for key in ("beos", "items", "results", "data"):
if isinstance(data.get(key), list):
return data[key]
return []

def destroy(
self,
beo_id: BeoId,
Expand Down
11 changes: 10 additions & 1 deletion bsp_sdk/biorecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Optional, Union
from .types import BioRecord, BioLevel, RangeObject, SourceMeta, RecordStatus
from .taxonomy import TaxonomyResolver
Expand Down Expand Up @@ -71,6 +72,15 @@ def set_unit(self, unit: str) -> "BioRecordBuilder":
return self

def set_collection_time(self, iso8601: str) -> "BioRecordBuilder":
"""Validate that iso8601 is a parseable ISO 8601 datetime string."""
# Normalise trailing 'Z' to '+00:00' so fromisoformat() accepts it on Python < 3.11
normalized = iso8601.replace("Z", "+00:00") if iso8601.endswith("Z") else iso8601
try:
datetime.fromisoformat(normalized)
except (ValueError, TypeError) as exc:
raise ValueError(
f'collection_time must be a valid ISO 8601 datetime string — got: "{iso8601}"'
) from exc
self._collected_at = iso8601
return self

Expand Down Expand Up @@ -125,7 +135,6 @@ def build(self) -> BioRecord:
if missing:
raise ValueError(f'BioRecord missing required fields: {", ".join(missing)}')

from datetime import datetime, timezone
now = datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")

source = SourceMeta(
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ license = { text = "Apache-2.0" }
authors = [{ name = "Ambrósio Institute", email = "dev@ambrosioinstitute.org" }]
keywords = ["bsp", "biological-sovereignty", "longevity", "health-data", "biomarker", "aptos", "blockchain"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand All @@ -30,6 +31,7 @@ dependencies = [
"aptos-sdk>=0.11.0,<0.12.0",
"pydantic>=2.0",
"cryptography>=41.0",
"ecdsa>=0.19",
]

# Security notice — ecdsa transitive dependency.
Expand Down
225 changes: 225 additions & 0 deletions tests/test_exchange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"""Tests for ExchangeClient — submit_records and read_records."""

from __future__ import annotations

from typing import Any, Optional

import pytest

from bsp_sdk.exchange import ExchangeClient
from bsp_sdk.http_client import BSPApiError
from bsp_sdk.types import BSPConfig, BioRecord, ReadFilters


# ── Fake HTTP client (same pattern as test_access.py) ─────────────────────────

class FakeHttp:
"""Records every request and returns pre-seeded responses."""

def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.responses: dict[tuple[str, str], Any] = {}
self.errors: dict[tuple[str, str], BSPApiError] = {}

def seed(self, method: str, path: str, response: Any) -> None:
self.responses[(method.upper(), path)] = response

def seed_error(self, method: str, path: str, err: BSPApiError) -> None:
self.errors[(method.upper(), path)] = err

def _dispatch(self, method: str, path: str, **kwargs: Any) -> Any:
self.calls.append({"method": method.upper(), "path": path, **kwargs})
key = (method.upper(), path)
if key in self.errors:
raise self.errors[key]
if key in self.responses:
return self.responses[key]
return {}

def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
return self._dispatch("GET", path, params=params)

def post(self, path: str, body: dict[str, Any]) -> Any:
return self._dispatch("POST", path, body=body)

def delete(self, path: str, body: Optional[dict[str, Any]] = None) -> Any:
return self._dispatch("DELETE", path, body=body)


# ── Fixtures ──────────────────────────────────────────────────────────────────

@pytest.fixture
def client() -> tuple[ExchangeClient, FakeHttp]:
config = BSPConfig(
ieo_domain="fleury.bsp",
private_key="a" * 64,
environment="local",
registry_url="http://localhost:3000",
)
http = FakeHttp()
return ExchangeClient(config, http=http), http # type: ignore[arg-type]


def _make_record(biomarker: str = "BSP-HM-001") -> dict[str, Any]:
"""Minimal dict that stands in for a BioRecord in HTTP payloads."""
return {
"record_id": "00000000-0000-0000-0000-000000000001",
"beo_id": "550e8400-e29b-41d4-a716-446655440000",
"ieo_id": "770e8400-e29b-41d4-a716-446655440001",
"version": "1.0.0",
"biomarker": biomarker,
"category": "BSP-HM",
"level": "STANDARD",
"value": 13.8,
"unit": "g/dL",
"collected_at": "2026-04-19T08:00:00Z",
"submitted_at": "2026-04-19T10:00:00Z",
"status": "ACTIVE",
"supersedes": None,
"confidence": None,
}


def _submit_result() -> dict[str, Any]:
return {
"request_id": "req-abc123",
"status": "SUCCESS",
"record_ids": ["00000000-0000-0000-0000-000000000001"],
"aptos_txs": ["0xdeadbeef"],
"timestamp": "2026-04-19T10:00:00Z",
}


def _read_result(records: list[dict[str, Any]] | None = None) -> dict[str, Any]:
if records is None:
records = [_make_record()]
return {
"request_id": "req-read-001",
"beo_id": "550e8400-e29b-41d4-a716-446655440000",
"records": records,
"total": len(records),
"has_more": False,
}


# ── submit_records ────────────────────────────────────────────────────────────

class TestSubmitRecords:
def test_submit_records_success(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
"""Happy path — HTTP 200 returns SubmitResult with aptos_txs."""
exc, http = client
http.seed("POST", "/api/exchange/records", _submit_result())

result = exc.submit_records(
target_beo="patient.bsp",
records=[_make_record()], # type: ignore[list-item]
consent_token="tok_abc",
)

assert result["request_id"] == "req-abc123"
assert result["status"] == "SUCCESS"
assert "0xdeadbeef" in result["aptos_txs"]

call = http.calls[0]
assert call["method"] == "POST"

def test_submit_empty_records_raises(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
exc, _ = client
with pytest.raises(ValueError, match="At least one BioRecord"):
exc.submit_records(
target_beo="patient.bsp",
records=[],
consent_token="tok_abc",
)

def test_submit_too_many_records_raises(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
exc, _ = client
records = [_make_record() for _ in range(101)] # type: ignore[misc]
with pytest.raises(ValueError, match="Maximum 100"):
exc.submit_records(
target_beo="patient.bsp",
records=records, # type: ignore[arg-type]
consent_token="tok_abc",
)


# ── read_records ──────────────────────────────────────────────────────────────

class TestReadRecords:
def test_read_records_success(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
"""Happy path — HTTP 200 returns ReadResult with records list."""
exc, http = client
http.seed("GET", "/api/exchange/records", _read_result())

result = exc.read_records(
target_beo="patient.bsp",
consent_token="tok_abc",
)

assert result["beo_id"] == "550e8400-e29b-41d4-a716-446655440000"
assert len(result["records"]) == 1
assert result["records"][0]["biomarker"] == "BSP-HM-001"
assert result["has_more"] is False

def test_read_records_with_filters(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
exc, http = client
http.seed("GET", "/api/exchange/records", _read_result([_make_record("BSP-LA-001")]))

result = exc.read_records(
target_beo="patient.bsp",
consent_token="tok_abc",
filters=ReadFilters(categories=["BSP-LA"]),
)

assert result["records"][0]["biomarker"] == "BSP-LA-001"

def test_read_records_empty_list(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
exc, http = client
http.seed("GET", "/api/exchange/records", _read_result([]))

result = exc.read_records(
target_beo="patient.bsp",
consent_token="tok_abc",
)

assert result["records"] == []
assert result["total"] == 0


# ── token requirements ────────────────────────────────────────────────────────

class TestTokenRequired:
def test_submit_requires_token(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
"""Calling submit_records with empty consent_token must raise."""
exc, _ = client
with pytest.raises((ValueError, TypeError, Exception)):
exc.submit_records(
target_beo="patient.bsp",
records=[_make_record()], # type: ignore[list-item]
consent_token="",
)

def test_read_requires_token(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
"""Calling read_records with empty consent_token must raise."""
exc, _ = client
with pytest.raises((ValueError, TypeError, Exception)):
exc.read_records(
target_beo="patient.bsp",
consent_token="",
)

def test_expired_token_raises_bsp_error(self, client: tuple[ExchangeClient, FakeHttp]) -> None:
"""API returning TOKEN_EXPIRED (403) propagates as BSPApiError."""
exc, http = client
http.seed_error(
"GET", "/api/exchange/records",
BSPApiError("TOKEN_EXPIRED", 403),
)

with pytest.raises(BSPApiError) as exc_info:
exc.read_records(
target_beo="patient.bsp",
consent_token="tok_expired",
)

assert exc_info.value.status_code == 403
Loading