sbn-sdk 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sbn_sdk-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: sbn-sdk
3
+ Version: 0.3.0
4
+ Summary: Python SDK for the SmartBlocks Network — attestation, GEC compute, SnapChore integrity, governance, and more.
5
+ License: MIT
6
+ Keywords: smartblocks,sbn,snapchore,gec,attestation,integrity
7
+ Author: SmartBlocks Team
8
+ Author-email: devrel@smartblocks.network
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Dist: PyJWT (>=2.8)
21
+ Requires-Dist: cryptography (>=41.0)
22
+ Requires-Dist: httpx (>=0.27.0,<0.28.0)
23
+ Project-URL: Documentation, https://smartblocks.network/docs/sdk
24
+ Project-URL: Homepage, https://smartblocks.network
25
+ Project-URL: Repository, https://github.com/smartblocks-network/sbn-sdk
26
+ Description-Content-Type: text/markdown
27
+
28
+ # SmartBlocks Network Python SDK
29
+
30
+ Canonical Python client for the SBN infrastructure. Covers the full network
31
+ surface — gateway, SnapChore, console, and control plane.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install sbn-sdk
37
+ # or from source
38
+ cd sdk/python && pip install -e .
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ ```python
44
+ from sbn import SbnClient
45
+
46
+ client = SbnClient(base_url="https://api.smartblocks.network")
47
+ client.authenticate_api_key("sbn_live_abc123")
48
+
49
+ # SnapChore — capture, verify, seal
50
+ block = client.snapchore.capture({"event": "signup", "user": "u-42"})
51
+ client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
52
+ client.snapchore.seal(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
53
+
54
+ # Gateway — slots, receipts, attestations
55
+ slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
56
+ receipt = client.gateway.fetch_receipt(slot.receipt_id)
57
+
58
+ # Console — API keys, usage, billing
59
+ keys = client.console.list_api_keys("proj-123")
60
+ usage = client.console.get_usage("proj-123")
61
+
62
+ # Control plane — rate plans, tenants, validators
63
+ plans = client.control_plane.list_rate_plans()
64
+ client.control_plane.create_tenant(
65
+ name="Acme Corp",
66
+ contact_email="ops@acme.co",
67
+ aggregator_endpoint="https://agg.acme.co",
68
+ rate_plan_id=plans[0].id,
69
+ )
70
+ ```
71
+
72
+ ## Auth methods
73
+
74
+ ```python
75
+ # API key (most common for external devs)
76
+ client.authenticate_api_key("sbn_live_...")
77
+
78
+ # Bearer token (console sessions, service-to-service)
79
+ client.authenticate_bearer("eyJ...")
80
+
81
+ # Ed25519 signing key (auto-refreshing JWTs for agents)
82
+ from sbn import SigningKey
83
+ key = SigningKey.from_pem("/path/to/key.pem", issuer="my-svc", audience="sbn")
84
+ client.authenticate_signing_key(key, scopes=["attest.write", "snapchore.seal"])
85
+ ```
86
+
87
+ ## Sub-clients
88
+
89
+ | Property | Domain | Key operations |
90
+ |----------|--------|----------------|
91
+ | `client.gateway` | Slots & receipts | `create_slot`, `close_slot`, `fetch_receipt`, `request_attestation` |
92
+ | `client.snapchore` | Hash capture | `capture`, `verify`, `seal`, `create_chain`, `append_to_chain` |
93
+ | `client.console` | Developer console | `list_api_keys`, `create_api_key`, `get_usage`, `get_billing_status` |
94
+ | `client.control_plane` | Multi-tenancy | `list_rate_plans`, `create_tenant`, `register_validator` |
95
+
96
+ ## Legacy compatibility
97
+
98
+ The original `sbn_gateway.py` single-file SDK is preserved for backward
99
+ compatibility. New integrations should use `from sbn import SbnClient`.
100
+
@@ -0,0 +1,72 @@
1
+ # SmartBlocks Network Python SDK
2
+
3
+ Canonical Python client for the SBN infrastructure. Covers the full network
4
+ surface — gateway, SnapChore, console, and control plane.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install sbn-sdk
10
+ # or from source
11
+ cd sdk/python && pip install -e .
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ from sbn import SbnClient
18
+
19
+ client = SbnClient(base_url="https://api.smartblocks.network")
20
+ client.authenticate_api_key("sbn_live_abc123")
21
+
22
+ # SnapChore — capture, verify, seal
23
+ block = client.snapchore.capture({"event": "signup", "user": "u-42"})
24
+ client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
25
+ client.snapchore.seal(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
26
+
27
+ # Gateway — slots, receipts, attestations
28
+ slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
29
+ receipt = client.gateway.fetch_receipt(slot.receipt_id)
30
+
31
+ # Console — API keys, usage, billing
32
+ keys = client.console.list_api_keys("proj-123")
33
+ usage = client.console.get_usage("proj-123")
34
+
35
+ # Control plane — rate plans, tenants, validators
36
+ plans = client.control_plane.list_rate_plans()
37
+ client.control_plane.create_tenant(
38
+ name="Acme Corp",
39
+ contact_email="ops@acme.co",
40
+ aggregator_endpoint="https://agg.acme.co",
41
+ rate_plan_id=plans[0].id,
42
+ )
43
+ ```
44
+
45
+ ## Auth methods
46
+
47
+ ```python
48
+ # API key (most common for external devs)
49
+ client.authenticate_api_key("sbn_live_...")
50
+
51
+ # Bearer token (console sessions, service-to-service)
52
+ client.authenticate_bearer("eyJ...")
53
+
54
+ # Ed25519 signing key (auto-refreshing JWTs for agents)
55
+ from sbn import SigningKey
56
+ key = SigningKey.from_pem("/path/to/key.pem", issuer="my-svc", audience="sbn")
57
+ client.authenticate_signing_key(key, scopes=["attest.write", "snapchore.seal"])
58
+ ```
59
+
60
+ ## Sub-clients
61
+
62
+ | Property | Domain | Key operations |
63
+ |----------|--------|----------------|
64
+ | `client.gateway` | Slots & receipts | `create_slot`, `close_slot`, `fetch_receipt`, `request_attestation` |
65
+ | `client.snapchore` | Hash capture | `capture`, `verify`, `seal`, `create_chain`, `append_to_chain` |
66
+ | `client.console` | Developer console | `list_api_keys`, `create_api_key`, `get_usage`, `get_billing_status` |
67
+ | `client.control_plane` | Multi-tenancy | `list_rate_plans`, `create_tenant`, `register_validator` |
68
+
69
+ ## Legacy compatibility
70
+
71
+ The original `sbn_gateway.py` single-file SDK is preserved for backward
72
+ compatibility. New integrations should use `from sbn import SbnClient`.
@@ -0,0 +1,38 @@
1
+ [tool.poetry]
2
+ name = "sbn-sdk"
3
+ version = "0.3.0"
4
+ description = "Python SDK for the SmartBlocks Network — attestation, GEC compute, SnapChore integrity, governance, and more."
5
+ authors = ["SmartBlocks Team <devrel@smartblocks.network>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ packages = [{include = "sbn"}, {include = "tower"}]
9
+ keywords = ["smartblocks", "sbn", "snapchore", "gec", "attestation", "integrity"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+
22
+ [tool.poetry.urls]
23
+ Homepage = "https://smartblocks.network"
24
+ Repository = "https://github.com/smartblocks-network/sbn-sdk"
25
+ Documentation = "https://smartblocks.network/docs/sdk"
26
+
27
+ [tool.poetry.dependencies]
28
+ python = "^3.10"
29
+ httpx = "^0.27.0"
30
+ cryptography = ">=41.0"
31
+ PyJWT = ">=2.8"
32
+
33
+ [tool.poetry.group.dev.dependencies]
34
+ pytest = "^8.2"
35
+
36
+ [build-system]
37
+ requires = ["poetry-core>=1.5.0"]
38
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,102 @@
1
+ """SmartBlocks Network SDK — canonical client for the SBN infrastructure.
2
+
3
+ Usage::
4
+
5
+ from sbn import SbnClient
6
+
7
+ client = SbnClient(base_url="https://api.smartblocks.network")
8
+ client.authenticate_api_key("sbn_live_abc123")
9
+
10
+ # SnapChore
11
+ block = client.snapchore.capture({"event": "signup", "user": "u-42"})
12
+ ok = client.snapchore.verify(block["snapchore_hash"], {"event": "signup", "user": "u-42"})
13
+
14
+ # Gateway (slots / receipts / attestations)
15
+ slot = client.gateway.create_slot(worker_id="w-1", task_type="classify")
16
+ receipt = client.gateway.fetch_receipt(slot.receipt_id)
17
+
18
+ # Console (projects, API keys, usage)
19
+ keys = client.console.list_api_keys()
20
+ usage = client.console.get_usage()
21
+
22
+ # GEC (frontier registry, compute, health)
23
+ result = client.gec.compute(y=85.0, x=100.0, frontier_id="core-ops")
24
+
25
+ # Governance (proposal lifecycle)
26
+ proposals = client.governance.list_proposals()
27
+
28
+ # Control plane (tenants, rate plans, validators)
29
+ plans = client.control_plane.list_rate_plans()
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ __version__ = "0.3.0"
35
+
36
+ # Re-export top-level conveniences
37
+ from sbn.client import SbnClient
38
+ from sbn.auth import SigningKey, MintedToken
39
+ from sbn._http import SbnError, SbnTransportError, RetryConfig
40
+ from sbn.gateway import (
41
+ GatewayClient,
42
+ SlotCreateRequest,
43
+ SlotHandle,
44
+ SlotClosure,
45
+ SlotSummary,
46
+ Receipt,
47
+ )
48
+ from sbn.snapchore import SnapChoreClient
49
+ from sbn.console import ConsoleClient, ApiKeyRecord, ApiKeyLimits, ProjectUsage
50
+ from sbn.control_plane import (
51
+ ControlPlaneClient,
52
+ RatePlan,
53
+ TenantSummary,
54
+ TenantDetail,
55
+ Validator,
56
+ )
57
+ from sbn.lattice import LatticeClient
58
+ from sbn.reality import RealityClient
59
+ from sbn.blocks import BlocksClient
60
+ from sbn.gec import GecClient
61
+ from sbn.governance import GovernanceClient
62
+
63
+ __all__ = [
64
+ "SbnClient",
65
+ # Auth
66
+ "SigningKey",
67
+ "MintedToken",
68
+ # HTTP / errors
69
+ "SbnError",
70
+ "SbnTransportError",
71
+ "RetryConfig",
72
+ # Gateway
73
+ "GatewayClient",
74
+ "SlotCreateRequest",
75
+ "SlotHandle",
76
+ "SlotClosure",
77
+ "SlotSummary",
78
+ "Receipt",
79
+ # SnapChore
80
+ "SnapChoreClient",
81
+ # Console
82
+ "ConsoleClient",
83
+ "ApiKeyRecord",
84
+ "ApiKeyLimits",
85
+ "ProjectUsage",
86
+ # Control plane
87
+ "ControlPlaneClient",
88
+ "RatePlan",
89
+ "TenantSummary",
90
+ "TenantDetail",
91
+ "Validator",
92
+ # Lattice
93
+ "LatticeClient",
94
+ # Reality Check
95
+ "RealityClient",
96
+ # Blocks
97
+ "BlocksClient",
98
+ # GEC
99
+ "GecClient",
100
+ # Governance
101
+ "GovernanceClient",
102
+ ]
@@ -0,0 +1,220 @@
1
+ """Shared HTTP transport with retry, error handling, and auth injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Mapping, MutableMapping, Sequence
8
+
9
+ import httpx
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Error types
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class SbnError(RuntimeError):
18
+ """Raised when the SBN API responds with an error payload."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ *,
24
+ status_code: int,
25
+ code: str | None = None,
26
+ details: Mapping[str, Any] | None = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+ self.code = code
31
+ self.details = dict(details or {})
32
+
33
+ def __str__(self) -> str:
34
+ base = super().__str__()
35
+ parts = [f"status={self.status_code}"]
36
+ if self.code:
37
+ parts.append(f"code={self.code}")
38
+ if self.details:
39
+ parts.append(f"details={self.details}")
40
+ return f"{base} ({', '.join(parts)})"
41
+
42
+
43
+ class SbnTransportError(RuntimeError):
44
+ """Raised when the SDK cannot reach SBN after retries."""
45
+
46
+ def __init__(self, message: str, *, last_exception: Exception | None = None) -> None:
47
+ super().__init__(message)
48
+ self.last_exception = last_exception
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Retry config
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class RetryConfig:
58
+ """Retry configuration for transient failures."""
59
+
60
+ attempts: int = 3
61
+ backoff_factor: float = 0.5
62
+ status_forcelist: Sequence[int] = (500, 502, 503, 504)
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Auth state
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ @dataclass
71
+ class AuthState:
72
+ """Mutable auth state shared across sub-clients."""
73
+
74
+ api_key: str | None = None
75
+ bearer_token: str | None = None
76
+ tenant_id: str | None = None
77
+ token_provider: Callable[[], str] | None = None
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # HTTP transport
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class HttpTransport:
86
+ """Thin wrapper around httpx.Client with retry and auth injection."""
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ base_url: str,
92
+ auth: AuthState,
93
+ retry: RetryConfig | None = None,
94
+ timeout: float = 10.0,
95
+ user_agent: str = "sbn-sdk/0.2",
96
+ transport: httpx.BaseTransport | None = None,
97
+ ) -> None:
98
+ self._base_url = base_url.rstrip("/")
99
+ self._auth = auth
100
+ self._retry = retry or RetryConfig()
101
+ self._client = httpx.Client(
102
+ base_url=self._base_url,
103
+ timeout=timeout,
104
+ headers={"User-Agent": user_agent},
105
+ transport=transport,
106
+ )
107
+
108
+ @property
109
+ def base_url(self) -> str:
110
+ return self._base_url
111
+
112
+ def close(self) -> None:
113
+ self._client.close()
114
+
115
+ # ------------------------------ auth headers ---------------------------
116
+
117
+ def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
118
+ headers: dict[str, str] = {}
119
+ if self._auth.tenant_id:
120
+ headers["X-Tenant-ID"] = self._auth.tenant_id
121
+
122
+ # API key auth takes precedence
123
+ if self._auth.api_key:
124
+ headers["x-api-key"] = self._auth.api_key
125
+ elif self._auth.token_provider:
126
+ headers["Authorization"] = f"Bearer {self._auth.token_provider()}"
127
+ elif self._auth.bearer_token:
128
+ headers["Authorization"] = f"Bearer {self._auth.bearer_token}"
129
+
130
+ if extra:
131
+ headers.update(extra)
132
+ return headers
133
+
134
+ # ------------------------------ request core ---------------------------
135
+
136
+ def request(
137
+ self,
138
+ method: str,
139
+ path: str,
140
+ *,
141
+ headers: Mapping[str, str] | None = None,
142
+ **kwargs: Any,
143
+ ) -> httpx.Response:
144
+ attempts = max(1, self._retry.attempts)
145
+ backoff = max(0.0, self._retry.backoff_factor)
146
+ method_upper = method.upper()
147
+
148
+ for attempt in range(1, attempts + 1):
149
+ try:
150
+ response = self._client.request(
151
+ method_upper,
152
+ path,
153
+ headers=self._headers(headers),
154
+ **kwargs,
155
+ )
156
+ except httpx.RequestError as exc:
157
+ if attempt >= attempts:
158
+ raise SbnTransportError(
159
+ "Failed to reach SBN", last_exception=exc
160
+ ) from exc
161
+ time.sleep(backoff)
162
+ backoff *= 2
163
+ continue
164
+
165
+ if (
166
+ response.status_code in self._retry.status_forcelist
167
+ and attempt < attempts
168
+ ):
169
+ response.close()
170
+ time.sleep(backoff)
171
+ backoff *= 2
172
+ continue
173
+
174
+ if response.status_code >= 400:
175
+ raise _error_from_response(response)
176
+ return response
177
+
178
+ raise SbnTransportError("Exhausted retries reaching SBN")
179
+
180
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
181
+ return self.request("GET", path, **kwargs)
182
+
183
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
184
+ return self.request("POST", path, **kwargs)
185
+
186
+ def patch(self, path: str, **kwargs: Any) -> httpx.Response:
187
+ return self.request("PATCH", path, **kwargs)
188
+
189
+ def delete(self, path: str, **kwargs: Any) -> httpx.Response:
190
+ return self.request("DELETE", path, **kwargs)
191
+
192
+
193
+ def _error_from_response(response: httpx.Response) -> SbnError:
194
+ try:
195
+ payload = response.json()
196
+ except ValueError:
197
+ payload = None
198
+
199
+ message = response.reason_phrase or "SBN request failed"
200
+ code: str | None = None
201
+ details: MutableMapping[str, Any] = {}
202
+
203
+ if isinstance(payload, Mapping):
204
+ if isinstance(payload.get("error"), Mapping):
205
+ err = payload["error"]
206
+ message = str(err.get("message") or message)
207
+ code = err.get("code")
208
+ if isinstance(err.get("details"), Mapping):
209
+ details.update(err["details"])
210
+ elif isinstance(payload.get("error"), str):
211
+ code = payload["error"]
212
+ message = str(payload.get("detail") or payload.get("message") or message)
213
+ else:
214
+ message = str(payload.get("message") or payload.get("detail") or message)
215
+ if isinstance(payload.get("details"), Mapping):
216
+ details.update(payload["details"])
217
+ elif payload is not None:
218
+ details["response"] = payload
219
+
220
+ return SbnError(message, status_code=response.status_code, code=code, details=details)
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,136 @@
1
+ """Ed25519 signing and JWT minting for SBN service-to-service auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Mapping, Sequence
13
+
14
+ from cryptography.hazmat.primitives import serialization
15
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
16
+
17
+
18
+ def _b64url(data: bytes) -> str:
19
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
20
+
21
+
22
+ def _canonical_json(data: Mapping[str, Any]) -> bytes:
23
+ return json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
24
+
25
+
26
+ def _now_utc() -> datetime:
27
+ return datetime.now(timezone.utc)
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class MintedToken:
32
+ """A freshly minted bearer token and its expiry."""
33
+
34
+ token: str
35
+ expires_at: datetime
36
+
37
+ @classmethod
38
+ def from_ttl(cls, token: str, ttl_seconds: int) -> MintedToken:
39
+ return cls(token=token, expires_at=_now_utc() + timedelta(seconds=ttl_seconds))
40
+
41
+ @property
42
+ def expired(self) -> bool:
43
+ return _now_utc() >= self.expires_at
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class SigningKey:
48
+ """Ed25519 signing helper for JWT minting and payload signatures."""
49
+
50
+ private_key: Ed25519PrivateKey
51
+ issuer: str
52
+ audience: str
53
+ kid: str = "sbn-ed25519"
54
+
55
+ @classmethod
56
+ def from_pem(
57
+ cls,
58
+ source: str | bytes | os.PathLike[str],
59
+ *,
60
+ issuer: str,
61
+ audience: str,
62
+ kid: str = "sbn-ed25519",
63
+ ) -> SigningKey:
64
+ if isinstance(source, (str, os.PathLike)):
65
+ text = str(source)
66
+ path = Path(text)
67
+ pem_data = path.read_bytes() if path.exists() else text.encode("utf-8")
68
+ else:
69
+ pem_data = bytes(source)
70
+ key = serialization.load_pem_private_key(pem_data, password=None)
71
+ if not isinstance(key, Ed25519PrivateKey):
72
+ raise TypeError("Expected an Ed25519 private key")
73
+ return cls(private_key=key, issuer=issuer, audience=audience, kid=kid)
74
+
75
+ @classmethod
76
+ def generate(
77
+ cls,
78
+ *,
79
+ issuer: str,
80
+ audience: str,
81
+ kid: str = "sbn-ed25519",
82
+ ) -> SigningKey:
83
+ return cls(
84
+ private_key=Ed25519PrivateKey.generate(),
85
+ issuer=issuer,
86
+ audience=audience,
87
+ kid=kid,
88
+ )
89
+
90
+ def issue_token(
91
+ self,
92
+ *,
93
+ subject: str,
94
+ scopes: Sequence[str],
95
+ cdna: str,
96
+ ttl_seconds: int,
97
+ tenant_id: str | None = None,
98
+ role: str = "agent",
99
+ ) -> MintedToken:
100
+ if ttl_seconds <= 0:
101
+ raise ValueError("ttl_seconds must be positive")
102
+
103
+ issued_at = _now_utc()
104
+ scope_list = sorted({s.strip() for s in scopes if s})
105
+ header = {"alg": "EdDSA", "typ": "JWT", "kid": self.kid}
106
+ payload: dict[str, Any] = {
107
+ "iss": self.issuer,
108
+ "aud": self.audience,
109
+ "sub": subject,
110
+ "role": role,
111
+ "scope": scope_list,
112
+ "cdna": cdna,
113
+ "iat": int(issued_at.timestamp()),
114
+ "exp": int((issued_at + timedelta(seconds=ttl_seconds)).timestamp()),
115
+ "jti": uuid.uuid4().hex,
116
+ }
117
+ if tenant_id:
118
+ payload["tenant_id"] = tenant_id
119
+
120
+ signing_input = f"{_b64url(_canonical_json(header))}.{_b64url(_canonical_json(payload))}"
121
+ signature = self.private_key.sign(signing_input.encode("utf-8"))
122
+ token = f"{signing_input}.{_b64url(signature)}"
123
+ return MintedToken.from_ttl(token, ttl_seconds)
124
+
125
+ def sign_payload(self, data: Mapping[str, Any]) -> str:
126
+ """Sign arbitrary canonical JSON and return the base64url signature."""
127
+ canonical = _canonical_json(data)
128
+ signature = self.private_key.sign(canonical)
129
+ return _b64url(signature)
130
+
131
+ def private_key_pem(self) -> bytes:
132
+ return self.private_key.private_bytes(
133
+ encoding=serialization.Encoding.PEM,
134
+ format=serialization.PrivateFormat.PKCS8,
135
+ encryption_algorithm=serialization.NoEncryption(),
136
+ )