diff --git a/.gitignore b/.gitignore index ad915d8..3858c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ build/ venv/ .pytest_cache/ .mypy_cache/ +docs/_build/ +docs/_autosummary/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b74be..51aaf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [2.1.0] — 2026-04-20 + +### Added + +- **AccessManager**: implemented `verify_consent` and `revoke_consent`. +- **Sphinx documentation**: autogenerated API reference (`docs/`, `sphinx-rtd-theme`). +- **CONTRIBUTING.md**: local setup, testing, linting, examples, PR guidelines. +- **Examples**: split `full_flow.py` into four focused, runnable examples: + - `examples/01_create_beo.py` + - `examples/02_grant_consent.py` + - `examples/03_submit_biorecord.py` + - `examples/04_destroy_beo.py` +- New optional extras: `pip install -e ".[docs]"` and `pip install -e ".[dev]"`. + +### Security + +- Force `ecdsa>=0.19.2` via uv override to patch CVE-2026-33936. + +### Infrastructure + +- CodeQL and Dependabot workflows added to the repository. + ## [1.0.0] — 2026-03-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..22a9309 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# Contributing to BSP Python SDK + +Thanks for your interest in contributing to the Biological Sovereignty Protocol Python SDK. + +## Prerequisites + +- Python `>=3.10` +- Git +- (Optional) [uv](https://github.com/astral-sh/uv) for fast env management + +## Local setup (venv + pip) + +```bash +git clone https://github.com/Biological-Sovereignty-Protocol/bsp-sdk-python.git +cd bsp-sdk-python +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Local setup (uv — recommended) + +```bash +uv venv +source .venv/bin/activate +uv pip install -e ".[dev,docs]" +``` + +The `uv` path automatically applies the `ecdsa>=0.19.2` override defined in `pyproject.toml` (see `SECURITY.md` for context). + +## Run tests + +```bash +pytest +pytest --cov=bsp_sdk --cov-report=term-missing +``` + +## Lint & type-check + +```bash +ruff check . +mypy bsp_sdk +``` + +## Build documentation + +Install optional docs extras and build with Sphinx: + +```bash +pip install -e ".[docs]" +sphinx-build docs docs/_build +``` + +Then open `docs/_build/index.html`. The `_build/` folder is git-ignored. + +## Run examples + +Runnable examples live in `examples/`: + +```bash +python examples/01_create_beo.py +python examples/02_grant_consent.py +python examples/03_submit_biorecord.py +python examples/04_destroy_beo.py +``` + +Without `BSP_RELAYER_URL` set, examples run in simulated mode. + +## Code style + +- Ruff enforces formatting (line-length 100, target py310). +- Public APIs must have type hints. The project is `mypy --strict` clean. +- Google-style or NumPy-style docstrings on all public functions/classes (picked up by Sphinx Napoleon). +- Names follow PEP 8: `snake_case` for functions/variables, `PascalCase` for classes. + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(access): add batch revoke API +fix(crypto): validate Ed25519 signature length +docs(sdk-python): expand BEOClient usage examples +``` + +## Pull requests + +1. Fork or branch from `main`: `git checkout -b feat/my-thing`. +2. Add or update tests. New public functions must ship with coverage. +3. Run `pytest && ruff check . && mypy bsp_sdk` locally before pushing. +4. Update `CHANGELOG.md` under the `[Unreleased]` section. +5. Open a PR against `main` with a clear summary and link to any related issue. +6. Be responsive to review feedback. + +## Security + +Do **not** file public issues for security vulnerabilities. See `SECURITY.md` for private disclosure instructions. + +## License + +By contributing you agree that your contributions are licensed under the project's Apache-2.0 license. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..0e11e92 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal Sphinx Makefile + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +.PHONY: help clean html + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) + +clean: + rm -rf "$(BUILDDIR)" + +html: + @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) + +%: + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..60d73f4 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,66 @@ +# Sphinx configuration for bsp-sdk (Python) +# Docs: https://www.sphinx-doc.org/en/master/usage/configuration.html + +from __future__ import annotations + +import os +import sys +from datetime import datetime + +# -- Path setup -------------------------------------------------------------- + +sys.path.insert(0, os.path.abspath("..")) + +# -- Project information ----------------------------------------------------- + +project = "BSP Python SDK" +author = "Ambrósio Institute" +copyright = f"{datetime.now().year}, {author}" + +# Read version from pyproject.toml without importing the package +try: + import tomllib # Python 3.11+ +except ImportError: # pragma: no cover + import tomli as tomllib # type: ignore + +_pyproject = os.path.join(os.path.dirname(__file__), "..", "pyproject.toml") +with open(_pyproject, "rb") as f: + release = tomllib.load(f)["project"]["version"] + +version = ".".join(release.split(".")[:2]) + +# -- General configuration --------------------------------------------------- + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinx.ext.autosummary", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "undoc-members": True, + "show-inheritance": True, +} + +napoleon_google_docstring = True +napoleon_numpy_docstring = True + +autosummary_generate = True + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "pydantic": ("https://docs.pydantic.dev/latest/", None), +} + +# -- HTML output ------------------------------------------------------------- + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +html_title = f"{project} {release}" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..6de5c76 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,58 @@ +BSP Python SDK +============== + +Official Python SDK for the **Biological Sovereignty Protocol** (BSP). + +Installation +------------ + +.. code-block:: bash + + pip install bsp-sdk + +Quick start +----------- + +.. code-block:: python + + from bsp_sdk import BSPClient + import os + + client = BSPClient( + ieo_domain = "fleury.bsp", + private_key = os.environ["BSP_IEO_PRIVATE_KEY"], + environment = "mainnet", + ) + +See ``examples/`` in the source tree for runnable end-to-end flows. + +API reference +------------- + +.. autosummary:: + :toctree: _autosummary + :recursive: + + bsp_sdk + +Modules +------- + +.. toctree:: + :maxdepth: 2 + + modules/client + modules/beo + modules/ieo + modules/biorecord + modules/access + modules/exchange + modules/crypto + modules/types + +Indices +------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/modules/access.rst b/docs/modules/access.rst new file mode 100644 index 0000000..c0a4d12 --- /dev/null +++ b/docs/modules/access.rst @@ -0,0 +1,7 @@ +Access / Consent +================ + +.. automodule:: bsp_sdk.access + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/beo.rst b/docs/modules/beo.rst new file mode 100644 index 0000000..3759fe7 --- /dev/null +++ b/docs/modules/beo.rst @@ -0,0 +1,7 @@ +BEO +=== + +.. automodule:: bsp_sdk.beo + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/biorecord.rst b/docs/modules/biorecord.rst new file mode 100644 index 0000000..22d68b5 --- /dev/null +++ b/docs/modules/biorecord.rst @@ -0,0 +1,7 @@ +BioRecord +========= + +.. automodule:: bsp_sdk.biorecord + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/client.rst b/docs/modules/client.rst new file mode 100644 index 0000000..e312785 --- /dev/null +++ b/docs/modules/client.rst @@ -0,0 +1,7 @@ +BSPClient +========= + +.. automodule:: bsp_sdk.client + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/crypto.rst b/docs/modules/crypto.rst new file mode 100644 index 0000000..962d6e9 --- /dev/null +++ b/docs/modules/crypto.rst @@ -0,0 +1,7 @@ +Crypto +====== + +.. automodule:: bsp_sdk.crypto + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/exchange.rst b/docs/modules/exchange.rst new file mode 100644 index 0000000..94173f6 --- /dev/null +++ b/docs/modules/exchange.rst @@ -0,0 +1,7 @@ +Exchange +======== + +.. automodule:: bsp_sdk.exchange + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/ieo.rst b/docs/modules/ieo.rst new file mode 100644 index 0000000..445160a --- /dev/null +++ b/docs/modules/ieo.rst @@ -0,0 +1,7 @@ +IEO +=== + +.. automodule:: bsp_sdk.ieo + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules/types.rst b/docs/modules/types.rst new file mode 100644 index 0000000..814099a --- /dev/null +++ b/docs/modules/types.rst @@ -0,0 +1,7 @@ +Types +===== + +.. automodule:: bsp_sdk.types + :members: + :undoc-members: + :show-inheritance: diff --git a/examples/01_create_beo.py b/examples/01_create_beo.py new file mode 100644 index 0000000..f8751e7 --- /dev/null +++ b/examples/01_create_beo.py @@ -0,0 +1,73 @@ +""" +Example 01 — Create a BEO (Biological Entity Object) + +Shows how to create a new BEO, capture its identifiers, and safely store +the returned private key / seed phrase. + +Run: + python examples/01_create_beo.py + +Env (optional): + BSP_RELAYER_URL — if unset, runs in SIMULATED mode + BSP_NETWORK — "mainnet" | "testnet" (default: testnet) + BSP_BEO_DOMAIN — default: alice.bsp +""" + +from __future__ import annotations + +import os +import secrets + +from bsp_sdk import BEOClient, BSPConfig + + +def main() -> None: + simulate = "BSP_RELAYER_URL" not in os.environ + domain = os.environ.get("BSP_BEO_DOMAIN", "alice.bsp") + + print("─── Example 01 — Create BEO ─────────────────────────────────") + print(f"Mode : {'SIMULATED' if simulate else 'LIVE'}") + print(f"Domain : {domain}") + print() + + if simulate: + print(f"[SIM] Would create BEO for domain: {domain}") + simulated = { + "beo_id": f"beo_{domain.replace('.', '_')}_{secrets.token_hex(4)}", + "domain": domain, + "aptos_tx": f"aptos_tx_{secrets.token_hex(6)}", + "private_key": f"BSP_BEO_PRIVATE_KEY_{secrets.token_hex(8)}", + "seed_phrase": "word1 word2 ... word24", + } + print() + print("BEO (simulated):") + for k, v in simulated.items(): + print(f" {k:11s}: {v}") + print() + print("⚠ Store private_key in .env as BSP_BEO_PRIVATE_KEY.") + print("⚠ Write the 24-word seed_phrase on paper and keep it offline.") + return + + config = BSPConfig( + ieo_domain=os.environ.get("BSP_IEO_DOMAIN", "bsp.network"), + private_key=os.environ.get("BSP_PRIVATE_KEY", ""), + environment=os.environ.get("BSP_NETWORK", "testnet"), + ) + beo_client = BEOClient(config) + + if beo_client.is_available(domain): + beo = beo_client.create(domain=domain) + print("BEO created:") + else: + beo = beo_client.resolve(domain) + print("BEO already exists:") + + print(f" beo_id : {beo.beo_id}") + print(f" domain : {beo.domain}") + print(f" status : {beo.status}") + print() + print("Next step: examples/02_grant_consent.py") + + +if __name__ == "__main__": + main() diff --git a/examples/02_grant_consent.py b/examples/02_grant_consent.py new file mode 100644 index 0000000..8ecd935 --- /dev/null +++ b/examples/02_grant_consent.py @@ -0,0 +1,79 @@ +""" +Example 02 — Grant consent from a BEO to an IEO + +Issues a ConsentToken that authorizes an institution (IEO) to submit and +read records for a specific individual (BEO) within a scoped set of +intents, categories, and levels. + +Run: + python examples/02_grant_consent.py +""" + +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone + +from bsp_sdk import AccessManager, BSPConfig + + +def main() -> None: + simulate = "BSP_RELAYER_URL" not in os.environ + + beo_domain = os.environ.get("BSP_BEO_DOMAIN", "alice.bsp") + ieo_domain = os.environ.get("BSP_IEO_DOMAIN", "genomicslab.bsp") + + expires_at = (datetime.now(tz=timezone.utc) + timedelta(days=90)).isoformat() + + print("─── Example 02 — Grant Consent ──────────────────────────────") + print(f"Mode : {'SIMULATED' if simulate else 'LIVE'}") + print(f"BEO : {beo_domain}") + print(f"IEO : {ieo_domain}") + print() + + if simulate: + token = { + "token_id": f"tok_{secrets.token_hex(4)}", + "beo_domain": beo_domain, + "ieo_domain": ieo_domain, + "intents": ["SUBMIT_RECORD", "READ_RECORDS"], + "categories": ["METABOLIC", "HORMONAL"], + "levels": ["CORE", "STANDARD"], + "granted_at": datetime.now(tz=timezone.utc).isoformat(), + "expires_at": expires_at, + "revoked_at": None, + } + print("ConsentToken (simulated):") + for k, v in token.items(): + print(f" {k:12s}: {v}") + print() + print("Next step: examples/03_submit_biorecord.py") + return + + config = BSPConfig( + ieo_domain=ieo_domain, + private_key=os.environ["BSP_PRIVATE_KEY"], + environment=os.environ.get("BSP_NETWORK", "testnet"), + ) + access = AccessManager(config) + + token = access.issue_consent( + beo_domain=beo_domain, + ieo_domain=ieo_domain, + intents=["SUBMIT_RECORD", "READ_RECORDS"], + categories=["METABOLIC", "HORMONAL"], + levels=["CORE", "STANDARD"], + expires_at=expires_at, + ) + + print("ConsentToken issued:") + print(f" token_id : {token.token_id}") + print(f" granted_at : {token.granted_at}") + print(f" expires_at : {token.expires_at}") + print() + print("Next step: examples/03_submit_biorecord.py") + + +if __name__ == "__main__": + main() diff --git a/examples/03_submit_biorecord.py b/examples/03_submit_biorecord.py new file mode 100644 index 0000000..0ab6723 --- /dev/null +++ b/examples/03_submit_biorecord.py @@ -0,0 +1,97 @@ +""" +Example 03 — Submit a signed BioRecord + +An IEO builds a BioRecord via BioRecordBuilder, signs it, and submits it +through ExchangeClient. Consent is verified automatically on the relayer. + +Run: + python examples/03_submit_biorecord.py +""" + +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timezone + +from bsp_sdk import BioRecordBuilder, BSPConfig, ExchangeClient + + +def main() -> None: + simulate = "BSP_RELAYER_URL" not in os.environ + + beo_domain = os.environ.get("BSP_BEO_DOMAIN", "alice.bsp") + ieo_domain = os.environ.get("BSP_IEO_DOMAIN", "genomicslab.bsp") + + print("─── Example 03 — Submit BioRecord ───────────────────────────") + print(f"Mode : {'SIMULATED' if simulate else 'LIVE'}") + print(f"BEO : {beo_domain}") + print(f"IEO : {ieo_domain}") + print() + + payload = { + "beo_domain": beo_domain, + "ieo_domain": ieo_domain, + "level": "CORE", + "category": "METABOLIC", + "taxonomy_code": "GLU-FAST-001", + "value": 92, + "unit": "mg/dL", + "collected_at": datetime.now(tz=timezone.utc).isoformat(), + "source": { + "device": "lab-analyzer-v3", + "method": "enzymatic", + "operator_id": "op_42", + }, + } + + print("BioRecord payload:") + for k, v in payload.items(): + print(f" {k:14s}: {v}") + print() + + if simulate: + result = { + "record_id": f"rec_{secrets.token_hex(4)}", + "submitted_at": datetime.now(tz=timezone.utc).isoformat(), + "aptos_tx": f"aptos_tx_{secrets.token_hex(6)}", + "status": "ACCEPTED", + } + print("SubmitResult (simulated):") + for k, v in result.items(): + print(f" {k:13s}: {v}") + print() + print("Next step: examples/04_destroy_beo.py") + return + + config = BSPConfig( + ieo_domain=ieo_domain, + private_key=os.environ["BSP_IEO_PRIVATE_KEY"], + environment=os.environ.get("BSP_NETWORK", "testnet"), + ) + + record = ( + BioRecordBuilder(config) + .for_beo(beo_domain) + .with_level("CORE") + .with_category("METABOLIC") + .with_code("GLU-FAST-001") + .with_value(92, "mg/dL") + .collected_at(payload["collected_at"]) + .build() + ) + + exchange = ExchangeClient(config) + result = exchange.submit_record(record) + + print("SubmitResult:") + print(f" record_id : {result.record_id}") + print(f" submitted_at : {result.submitted_at}") + print(f" aptos_tx : {result.aptos_tx}") + print(f" status : {result.status}") + print() + print("Next step: examples/04_destroy_beo.py") + + +if __name__ == "__main__": + main() diff --git a/examples/04_destroy_beo.py b/examples/04_destroy_beo.py new file mode 100644 index 0000000..456129e --- /dev/null +++ b/examples/04_destroy_beo.py @@ -0,0 +1,66 @@ +""" +Example 04 — Destroy / retire a BEO + +Sovereign exit flow: the individual revokes all active consent tokens and +then locks the BEO, rendering it permanently inaccessible without the +recovery seed phrase. + +A BEO cannot be forcibly deleted on-chain (immutability), but it can be +LOCKED. Re-activation requires the seed phrase used at creation time. + +Run: + python examples/04_destroy_beo.py +""" + +from __future__ import annotations + +import os +import secrets + +from bsp_sdk import AccessManager, BEOClient, BSPConfig + + +def main() -> None: + simulate = "BSP_RELAYER_URL" not in os.environ + beo_domain = os.environ.get("BSP_BEO_DOMAIN", "alice.bsp") + + print("─── Example 04 — Destroy / Lock BEO ─────────────────────────") + print(f"Mode : {'SIMULATED' if simulate else 'LIVE'}") + print(f"BEO : {beo_domain}") + print() + + if simulate: + print("[SIM] Step 1 — revoke all active consent tokens...") + print("[SIM] revoked 2 tokens") + print("[SIM] Step 2 — lock BEO on-chain...") + print("[SIM] beo status : LOCKED") + print(f"[SIM] aptos_tx : aptos_tx_{secrets.token_hex(6)}") + print() + print("BEO is now locked. To re-activate, use the 24-word seed phrase") + print("with BEOClient.recover(seed_phrase, domain).") + return + + config = BSPConfig( + ieo_domain=os.environ.get("BSP_IEO_DOMAIN", "bsp.network"), + private_key=os.environ["BSP_BEO_PRIVATE_KEY"], + environment=os.environ.get("BSP_NETWORK", "testnet"), + ) + + # Step 1 — revoke every active token + print("Step 1 — revoking all active consent tokens...") + access = AccessManager(config) + revoked = access.revoke_all_tokens(beo_domain=beo_domain) + print(f" revoked: {revoked}") + print() + + # Step 2 — lock the BEO + print("Step 2 — locking BEO...") + beo_client = BEOClient(config) + result = beo_client.lock(beo_domain) + print(f" status : {result.status}") + print() + print("BEO locked. The 24-word seed phrase is the only way back in.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 53843a5..7704d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,20 @@ dev = [ "mypy>=1.8", "ruff>=0.3", ] +docs = [ + "sphinx>=7.0", + "sphinx-rtd-theme>=2.0", +] + +[tool.hatch.envs.docs] +dependencies = [ + "sphinx>=7.0", + "sphinx-rtd-theme>=2.0", +] + +[tool.hatch.envs.docs.scripts] +build = "sphinx-build docs docs/_build" +clean = "rm -rf docs/_build docs/_autosummary" [project.urls] Homepage = "https://biologicalsovereigntyprotocol.com"