Metadata-Version: 2.4
Name: keyban-api-client
Version: 2.0.2
Summary: Python client for the Keyban DPP Passport API
Author-email: Keyban <support@keyban.io>
License: MIT License
        
        Copyright (c) 2024 Keyban
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://keyban.io
Keywords: keyban,dpp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: typing-extensions>=4.0.0
Requires-Dist: cryptography>=41.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: python-dotenv>=0.19.0; extra == "dev"
Dynamic: license-file

# Keyban API Client

Python client for the Keyban DPP Passport API. Create and manage model-granularity passports with on-chain certification on Starknet, field-level encryption, and typed Pydantic models.

## Features

- **Model passports** — create and update passports certified on Starknet
- **On-chain certification** — automatic W3C Verifiable Credential issuance (P-256 `ecdsa-jcs-2019`) anchored on Starknet, signed content uploaded to IPFS
- **Selective certification** — `certified_paths` to choose which fields from `data` go into the certificate; updating non-certified fields skips re-certification
- **Field-level encryption** — SHA-256 hashing (integrity) or AES-256-GCM (reversible) via `PassportData`
- **Typed responses** — Pydantic v2 models; `py.typed` marker for mypy users

## Install

```bash
pip install keyban-api-client
```

Requires Python 3.9+.

## Quick start

```python
from uuid import UUID
from keyban_api_client import PassportClient

client = PassportClient(api_key="your-api-key")
# base_url defaults to https://api.prod.keyban.io — pass it explicitly for staging/local:
#   PassportClient(api_key="...", base_url="https://api.staging.keyban.io")

app_id = UUID("00000000-0000-0000-0000-000000000000")  # ← replace with your application UUID

passport = client.create_passport_model(
    application=app_id,
    network="StarknetMainnet",
    model_number="my-model-001",
    data={"brand": "Acme", "gtin": "3760001000001"},
    certified_paths=["brand", "gtin"],
)

print(f"Passport ID: {passport.id}")
print(f"Token ID:    {passport.token_id}")       # derived immediately
print(f"IPFS CID:    {passport.ipfs_cid}")       # None right after creation — see below
```

### Certification is asynchronous

When `data` is provided on a model passport, the backend queues a certification job. The HTTP response returns immediately with `token_id` populated but `ipfs_cid` still `None`. The worker then:

1. Builds a W3C Verifiable Credential (`credentialSubject` = byte-for-byte certified content)
2. Uploads the signed VC to IPFS → populates `ipfs_cid`
3. Publishes a certification event on Starknet with the CID, a SHA-256 canonical content hash, and the certifier public key

Latency depends on the network: a few seconds on devnet, typically 15–20 seconds on Mainnet. The same asynchrony applies to `update_passport_model` when the certificate content changes — the response returns the previous CID; the new one appears on a later fetch. If the update does not change the certificate content (e.g. you touched only a field outside `certified_paths`), the CID stays the same — no re-certification is triggered.

A first-class helper to wait for certification status will land in a future version.

## Encryption

Protect sensitive fields locally before sending them to the API:

| Algorithm | Reversible | Use case |
|-----------|------------|----------|
| `sha256`  | No  | Integrity / existence proof |
| `aes-256-gcm` | Yes | Confidential data (decrypt with the key) |

```python
import base64, os
from keyban_api_client import PassportData

# SHA-256 (one-way hash) — encryption_key is NOT set on this path
data = PassportData.create_encrypted(
    confidential_paths=["supplier_id"],
    enc_algorithm="sha256",
    name="Public name",
    supplier_id="SECRET-123",
)
# data.model_dump() → {"name": "Public name", "supplier_id": "encrypted:sha256:<hex>"}

# AES-256-GCM (symmetric, reversible). Omit `enc_key` to let the SDK generate one;
# read it back via `data.encryption_key` (only set on the AES path).
data = PassportData.create_encrypted(
    confidential_paths=["serial_number", "brand.supplier_id"],  # dot-notation supported
    enc_algorithm="aes-256-gcm",
    name="Public",
    serial_number="SN-CONFIDENTIAL",
    brand={"name": "Public", "supplier_id": "SECRET"},
)
key_to_persist = data.encryption_key  # base64-encoded 32 bytes — store this in your secret manager
# data.model_dump() → {"serial_number": "encrypted:aes-256-gcm:<b64>", ...}

passport = client.create_passport_model(
    application=app_id,
    network="StarknetMainnet",
    model_number="enc-model",
    data=data.model_dump(),
    certified_paths=["serial_number"],
)
```

> **Do not log the AES key.** Treat `data.encryption_key` like any other secret — if it leaks into logs, the chiffré can be reversed.

### Payload format

Encrypted values carry an `encrypted:<algorithm>:<payload>` prefix.

- **SHA-256** payload is hex of `sha256(canonical_json_value)`. Irreversible — used for proof of existence/integrity only.
- **AES-256-GCM** payload is `base64(version || nonce || ciphertext || tag)` where `version = 0x01`, `nonce` is 12 bytes, and the AEAD tag (16 bytes) is appended to the ciphertext by `cryptography`'s AESGCM. AAD is the literal byte string `b"v1:aes-256-gcm"`.

**Security**: `create_encrypted` raises `ValueError` if a `confidential_paths` entry does not exist in the data. This prevents silently shipping unencrypted secrets on a typo.

## Filtering, pagination, listing

```python
from keyban_api_client import FilterOperator

# Filter values are strings — convert datetimes with .isoformat(), UUIDs with str(...)
filters = [
    FilterOperator(field="granularity", operator="eq", value="model"),
    FilterOperator(field="modelNumber", operator="eq", value="my-model-001"),
]
page = client.list_passports(filters=filters, current_page=1, page_size=50)

for p in page.data:
    print(f"- {p.id} {p.granularity} {p.model_number}")
print(f"total matching: {page.total}")

# Paginate. page_size > 100 returns HTTP 400 (not silently capped).
all_passports = []
cursor = 1
while True:
    resp = client.list_passports(current_page=cursor, page_size=100)
    all_passports.extend(resp.data)
    if not resp.data or len(all_passports) >= resp.total:
        break
    cursor += 1
```

> Today the public passport endpoint only accepts `eq` on `granularity` and `modelNumber`. Other field/operator combinations return HTTP 400. `field` uses the backend (camelCase) name — `modelNumber`, not `model_number`.

## Selective certification with `certified_paths`

By default (or with `certified_paths=[]`) the full `data` object is certified. Pass specific paths to certify only a subset — updating non-certified fields won't trigger re-certification:

```python
passport = client.create_passport_model(
    application=app_id,
    network="StarknetMainnet",
    model_number="selective",
    data={"brand": "Acme", "gtin": "3760001000001", "notes": "internal memo"},
    certified_paths=["brand", "gtin"],
)

# Update a non-certified field — NO re-certification
client.update_passport_model(
    passport.id,
    data={"brand": "Acme", "gtin": "3760001000001", "notes": "updated memo"},
)

# Update a certified field — re-certification triggers; new ipfs_cid after the job runs
client.update_passport_model(
    passport.id,
    data={"brand": "NewBrand", "gtin": "3760001000001", "notes": "updated memo"},
)

# Change the selection itself — re-certification triggers
client.update_passport_model(passport.id, certified_paths=["brand", "gtin", "notes"])
```

The backend only re-certifies when the resulting certificate content actually changes (content hash differs).

## Error handling

```python
from uuid import UUID
import requests
from keyban_api_client import PassportClient, KeybanAPIError

with PassportClient(api_key="...") as client:
    try:
        passport = client.get_passport(UUID("00000000-0000-0000-0000-000000000000"))
    except KeybanAPIError as e:
        print(e)                 # e.g. "HTTP 404: Not Found"
        print(e.status_code)     # 404
        print(e.detail)          # full RFC 7807 body: {"status", "title", "detail", ...}
    except requests.RequestException as e:
        # DNS failures, connection refused, read timeouts — see note below
        print(f"network error: {e}")
```

`KeybanAPIError` inherits from `requests.HTTPError` and is raised on **any non-2xx HTTP response**. Its `__str__` renders as `"HTTP {status} {title}: {detail}"`, with one shortcut: when `title == detail` (common for `404 Not Found`, `401 Unauthorized`, …) the message collapses to `"HTTP {status}: {title}"`. Structured access via `.status_code` and `.detail` remains available for programmatic flows.

> **Network-level failures are not wrapped.** Connection refused, DNS failures, TLS errors, and read timeouts surface as raw `requests.exceptions.*` (they never reach the HTTP layer where `KeybanAPIError` is raised). Catch `requests.RequestException` alongside `KeybanAPIError` if you need a single safety net.

## API reference

### `PassportClient(api_key, base_url="https://api.prod.keyban.io", api_version="v1", timeout=30)`

| Method | Signature | Notes |
|---|---|---|
| `list_passports` | `(filters=None, current_page=1, page_size=10) -> PassportListResponse` | Any granularity. `page_size > 100` → `HTTP 400` from the backend (no client-side check). |
| `get_passport` | `(passport_id: UUID) -> Passport` | Any granularity. The `UUID` type is advisory — strings are forwarded as-is and only validated server-side. |
| `create_passport_model` | `(*, application, network, model_number, data=None, certified_paths=None) -> Passport` | Granularity is always `model`. |
| `update_passport_model` | `(passport_id, *, data=None, certified_paths=None) -> Passport` | Re-certification triggers automatically when the certificate content changes. |
| `close()` | — | Closes the HTTP session. Also supports `with PassportClient(...) as client:`. |

### Public models

| Symbol | Role |
|---|---|
| `Passport` | Response model for a passport — see full field list below |
| `PassportListResponse` | `{data: List[Passport], total: int}` |
| `PassportData` | Helper to build the `data` dict — accepts arbitrary keyword arguments (`extra='allow'`); converts `date` to ISO strings and exposes `create_encrypted(...)`. **Note:** `datetime` values are truncated to date (the time component is dropped) — pre-format with `dt.isoformat()` if you need full timestamps. |
| `FilterOperator` | `{field: str, operator: str, value: str}` — values are forwarded with no client-side validation. |
| `KeybanAPIError` | Exception raised on any non-2xx HTTP response. Network-level failures are raised as `requests.exceptions.*` and not wrapped. |

### `Passport` fields

| Field | Type | Notes |
|---|---|---|
| `id` | `UUID` | Passport identifier. |
| `application` | object with `.id: UUID` | Application the passport belongs to. Access via `passport.application.id`. The concrete class is internal and not part of the public API. |
| `network` | `str` | e.g. `"StarknetMainnet"`, `"StarknetSepolia"`. The SDK forwards the string unchanged; the backend is the source of truth for the accepted set. |
| `granularity` | `str` | `"model"`, `"batch"`, or `"item"`. This SDK only creates `"model"`; reads are agnostic. |
| `model_number` | `Optional[str]` | Identifier for model granularity. |
| `batch_number` | `Optional[str]` | Identifier for batch granularity. |
| `item_number` | `Optional[str]` | Identifier for item granularity. |
| `data` | `Optional[Dict[str, Any]]` | Arbitrary passport data. |
| `certified_paths` | `Optional[List[str]]` | Dot-notation paths selecting which fields of `data` go into the certificate. Empty/`None` ⇒ the entire `data` is certified. |
| `token_id` | `Optional[str]` | On-chain identifier, populated immediately on create. |
| `ipfs_cid` | `Optional[str]` | IPFS CID of the signed VC. `None` right after create/update — see the "Certification is asynchronous" section. |
| `allowed_claim_email` | `Optional[str]` | Email allowed to claim this passport (item granularity only). |
| `created_at` | `datetime` | Returned as-is from the backend; expected to be timezone-aware UTC. |
| `updated_at` | `datetime` | Returned as-is from the backend; expected to be timezone-aware UTC. |

### API endpoints

All operations go through `/v1/dpp/passports`:

| Endpoint | Method |
|----------|--------|
| `/v1/dpp/passports` | GET, POST |
| `/v1/dpp/passports/:id` | GET, PATCH |

## Migrating to 2.0

2.0 focuses the public surface on passport-model operations. If you are on
0.0.x, apply both the `0.0.x → 1.0.0` steps and the `1.0.0 → 2.0` steps below.

### Quick upgrade

```bash
pip install --upgrade keyban-api-client
```

### Before / after

```python
# BEFORE (1.0.0)
from keyban_api_client import DppClient, CreatePassportRequest, ProductFields

client = DppClient(
    base_url="https://api.prod.keyban.io",
    api_key="...",
)
passport = client.create_passport(CreatePassportRequest(
    application=UUID("00000000-0000-0000-0000-000000000000"),
    network="StarknetMainnet",
    granularity="model",
    modelNumber="my-model",
    data={"brand": "Acme"},
    certifiedPaths=["brand"],
))
client.close()

# AFTER (2.0)
from keyban_api_client import PassportClient, PassportData

client = PassportClient(api_key="...")  # base_url defaults to prod
passport = client.create_passport_model(
    application=UUID("00000000-0000-0000-0000-000000000000"),
    network="StarknetMainnet",
    model_number="my-model",
    data={"brand": "Acme"},
    certified_paths=["brand"],
)
client.close()
```

### Renames

| 1.0.0 | 2.0.0 |
|---|---|
| `DppClient` | `PassportClient` |
| `DppPassport` | `Passport` |
| `ProductFields` | `PassportData` |
| `ProductClient` (alias) | removed — use `PassportClient` |

### Removed (with replacement guidance)

| Removed | Replacement |
|---|---|
| `client.create_passport(data)` | `client.create_passport_model(**kwargs)` — kwargs-only; granularity always `model` |
| `client.update_passport(id, data)` | `client.update_passport_model(id, **kwargs)` |
| `client.delete_passport(id)` | No SDK replacement — model passports anchor on-chain records and are intended to remain addressable |
| `CreatePassportRequest` | Pass fields directly as keyword arguments to `create_passport_model(...)` |
| `UpdatePassportRequest` | Pass fields directly as keyword arguments to `update_passport_model(...)` |
| `create_filter(field, op, value)` | `FilterOperator(field=..., operator=..., value=...)` |
| `search_passports(client, filters)` | `client.list_passports(filters=filters).data` |
| `QueryParams`, `DynamicFieldDef`, `Application` | Unused in public flows; made internal or removed |
| `playground.py` | Use the examples in this README. End-to-end tests (`test_client.py -m api`) are kept in the source repository, not shipped on PyPI. |

### Constructor signature

`PassportClient.__init__` swaps parameter order: `api_key` comes first, `base_url` is optional and defaults to `"https://api.prod.keyban.io"`.

```python
# 1.0.0 — base_url required and first
DppClient(base_url="...", api_key="...")

# 2.0.0 — api_key first; base_url optional
PassportClient(api_key="...")
PassportClient(api_key="...", base_url="https://api.staging.keyban.io")
```

### Write methods: kwargs-only, snake_case

`create_passport_model` and `update_passport_model` take named keyword arguments only. `granularity` is no longer part of the public surface (it is always `"model"` for these methods). Camel-case aliases (`modelNumber`, `certifiedPaths`) are not accepted — use `model_number`, `certified_paths`.

### Behavioral changes

- **`FilterOperator.value` is now `str`.** Non-string values raise a Pydantic validation error. Convert explicitly: `value=str(x)`, `value=dt.isoformat()`.
- **`page_size > 100` is no longer silently capped.** The backend returns `HTTP 400` with a clear error. Use `page_size<=100` and paginate.
- **Missing `confidential_paths` raises.** `PassportData.create_encrypted(confidential_paths=["typo"], ...)` now raises `ValueError` instead of logging a warning and leaving the data unencrypted — this prevents silent security failures.
- **`KeybanAPIError.__str__` format changed.** `print(e)` now renders `"HTTP 403 Forbidden: Invalid API key."` instead of just `"Forbidden"`. Attributes `e.status_code` and `e.detail` are unchanged. If your code parses `str(e)`, switch to the structured attributes.
- **`network` is no longer validated client-side.** The backend validates; the SDK passes the string through, so new networks work without a client upgrade.

### Read access on key passport fields

In 0.0.x, custom data fields lived at the top level of `Product`/`DppPassport` (`product.name`, `product.brand`, …). On `Passport`, custom data is nested under `data`:

```python
# BEFORE (0.0.x)
name = passport.name

# AFTER (2.0)
name = (passport.data or {}).get("name")
```

Anything not in the closed field list (`id`, `application`, `network`, `granularity`, `model_number`, `batch_number`, `item_number`, `data`, `certified_paths`, `token_id`, `ipfs_cid`, `allowed_claim_email`, `created_at`, `updated_at`) is now in `passport.data`.

### From 0.0.x

Apply the `0.0.x → 1.0.0` steps first:

- Move any `status`-driven workflow off the request body. Passports are always `published` from the user's perspective; the SDK injects `status: "published"` internally on create where the backend currently requires it.
- `certifiedPaths` (renamed `certified_paths` in 2.0) is available again on model passports. Omit or pass an empty list to certify the full `data`.
- The old "product" vocabulary (`ProductClient`, `ProductFields`, `CreateProductRequest`, …) has been fully replaced. Follow the renames table above.
- Move custom-field reads from `passport.<field>` to `passport.data["<field>"]` (see the section above).

Then apply the `1.0.0 → 2.0` changes listed in this document.

## Changelog

The full history is maintained in `CHANGELOG.md` in the source repository. Highlights:

- **2.0.2** — Documentation pass: filter example aligned with the operators the backend actually accepts (`eq` on `granularity` / `modelNumber`); AES payload format documented; `KeybanAPIError` `__str__` and network-error handling clarified; `passport.<field>` → `passport.data["<field>"]` migration note added.
- **2.0.1** — `create_passport_model` re-injects `status: "published"` to match the backend's Draft/Published flow (passports are certifiable immediately on create again).
- **2.0.0** — Public surface scoped to model-granularity passports. Renames: `DppClient` → `PassportClient`, `ProductFields` → `PassportData`. See the migration section above.

## License

MIT — this client is part of the DAP (Digital Asset Platform) by Keyban project.
