dominus-sdk-python 9.0.15__tar.gz → 9.1.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.
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/PKG-INFO +5 -4
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/README.md +4 -3
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/__init__.py +42 -1
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/cache.py +6 -6
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/core.py +260 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/start.py +143 -16
- dominus_sdk_python-9.1.0/dominus/target.py +342 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/PKG-INFO +5 -4
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/SOURCES.txt +3 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/pyproject.toml +16 -1
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_authority_public_vocabulary.py +73 -0
- dominus_sdk_python-9.1.0/tests/test_cache_contracts.py +115 -0
- dominus_sdk_python-9.1.0/tests/test_public_exports.py +137 -0
- dominus_sdk_python-9.1.0/tests/test_target_contract.py +556 -0
- dominus_sdk_python-9.1.0/tests/test_transport_compat.py +961 -0
- dominus_sdk_python-9.0.15/tests/test_public_exports.py +0 -51
- dominus_sdk_python-9.0.15/tests/test_transport_compat.py +0 -395
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/config/__init__.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/config/endpoints.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/__init__.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/envelope.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/contract/projections.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/errors.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/__init__.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/auth.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/console_capture.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/crypto.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/sse.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/helpers/trace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/__init__.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/admin.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/ai.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/artifacts.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/auth.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/authority.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/browser.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/coder.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/courier.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/db.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/ddl.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/deployer.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/fastapi.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/files.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/health.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/jobs.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/logs.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/platform.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/portal.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/processor.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/publisher.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/recipes.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/redis.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/secrets.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/secure.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/stash.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/sync.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/usage.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/warden.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/namespaces/workflow.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/py.typed +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus/services/__init__.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/dependency_links.txt +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/requires.txt +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/top_level.txt +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/setup.cfg +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_ai_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_artifacts_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_auth.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_browser_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_contract.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_control_plane_namespaces.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_error_recovery_action.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_errors.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_errors_aliases.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_flat_commands.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_health.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_logs.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_namespace_barrel_exports.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_platform_coder_namespaces.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_provisioning_parity.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_publisher_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_readme_quickstart_catalog.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_recipes_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_recipes_stash_routing.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_secrets_presence_contract.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_stash_artifact_facade.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_stash_tables_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_token_precedence.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_usage_namespace.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_version_consistency.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_workflow_lifecycle.py +0 -0
- {dominus_sdk_python-9.0.15 → dominus_sdk_python-9.1.0}/tests/test_workflow_refs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dominus-sdk-python
|
|
3
|
-
Version: 9.0
|
|
3
|
+
Version: 9.1.0
|
|
4
4
|
Summary: Python SDK for the Dominus gateway-first platform
|
|
5
5
|
Author-email: CareBridge Systems <dev@carebridge.io>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -38,8 +38,9 @@ Async Python SDK for the Dominus gateway-first service plane.
|
|
|
38
38
|
## Agent Guide
|
|
39
39
|
|
|
40
40
|
Start with `docs/agent-guide/INDEX.md`. The current snapshot is
|
|
41
|
-
`docs/agent-guide/2026-
|
|
42
|
-
cleanup audit is `docs/janitor/2026-
|
|
41
|
+
`docs/agent-guide/2026-10-16-1300-sdk-python/00-reading-order.md`; the latest
|
|
42
|
+
cleanup audit is `docs/janitor/2026-10-16-1300-sdk-python-cleanup-audit.md`.
|
|
43
|
+
Knowledge graph: graphify-out/GRAPH_REPORT.md — query with `graphify-run query "<question>"` before grepping.
|
|
43
44
|
|
|
44
45
|
## What This Repo Ships
|
|
45
46
|
|
|
@@ -48,7 +49,7 @@ cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
|
|
|
48
49
|
- Gateway-scoped client mode for MCP and other user-JWT sessions
|
|
49
50
|
- Transport compatibility for wrapped `{success,data}` responses and unwrapped Warden/control-plane success objects
|
|
50
51
|
- Local helpers for JWT verification, trace propagation, retries, and console capture
|
|
51
|
-
- Current package version: `9.0.
|
|
52
|
+
- Current package version: `9.0.16`
|
|
52
53
|
|
|
53
54
|
## Install
|
|
54
55
|
|
|
@@ -5,8 +5,9 @@ Async Python SDK for the Dominus gateway-first service plane.
|
|
|
5
5
|
## Agent Guide
|
|
6
6
|
|
|
7
7
|
Start with `docs/agent-guide/INDEX.md`. The current snapshot is
|
|
8
|
-
`docs/agent-guide/2026-
|
|
9
|
-
cleanup audit is `docs/janitor/2026-
|
|
8
|
+
`docs/agent-guide/2026-10-16-1300-sdk-python/00-reading-order.md`; the latest
|
|
9
|
+
cleanup audit is `docs/janitor/2026-10-16-1300-sdk-python-cleanup-audit.md`.
|
|
10
|
+
Knowledge graph: graphify-out/GRAPH_REPORT.md — query with `graphify-run query "<question>"` before grepping.
|
|
10
11
|
|
|
11
12
|
## What This Repo Ships
|
|
12
13
|
|
|
@@ -15,7 +16,7 @@ cleanup audit is `docs/janitor/2026-09-19-1300-sdk-python-cleanup-audit.md`.
|
|
|
15
16
|
- Gateway-scoped client mode for MCP and other user-JWT sessions
|
|
16
17
|
- Transport compatibility for wrapped `{success,data}` responses and unwrapped Warden/control-plane success objects
|
|
17
18
|
- Local helpers for JWT verification, trace propagation, retries, and console capture
|
|
18
|
-
- Current package version: `9.0.
|
|
19
|
+
- Current package version: `9.0.16`
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
|
@@ -148,6 +148,29 @@ from .helpers.core import (
|
|
|
148
148
|
mint_selected_scope_jwt,
|
|
149
149
|
)
|
|
150
150
|
|
|
151
|
+
# Export the exact-target surface. The shared kernel codec stays a lazy,
|
|
152
|
+
# out-of-band dependency (documented git-tag lane); importing this module never
|
|
153
|
+
# requires it, so the base install stays targetless.
|
|
154
|
+
from .target import (
|
|
155
|
+
EXACT_EXCHANGE_VERSION,
|
|
156
|
+
EXACT_TARGET_DEPENDENCY_LANE,
|
|
157
|
+
EXACT_TARGET_VERSION,
|
|
158
|
+
SERVICE_CORE_TAG,
|
|
159
|
+
TARGET_HEADER,
|
|
160
|
+
TOPOLOGY_TARGET_VERSION,
|
|
161
|
+
ExactTargetDependencyError,
|
|
162
|
+
ExactTargetError,
|
|
163
|
+
ExactTicketRecord,
|
|
164
|
+
assert_ticket_proves_target,
|
|
165
|
+
canonical_target_bytes,
|
|
166
|
+
selected_scope_target,
|
|
167
|
+
target_fingerprint,
|
|
168
|
+
target_header_value,
|
|
169
|
+
topology_target_hash,
|
|
170
|
+
validate_target,
|
|
171
|
+
validate_target_exchange_response,
|
|
172
|
+
)
|
|
173
|
+
|
|
151
174
|
# Export trace utilities for distributed tracing
|
|
152
175
|
from .helpers.trace import (
|
|
153
176
|
generate_trace_id,
|
|
@@ -191,7 +214,7 @@ from .errors import (
|
|
|
191
214
|
TimeoutError as DominusTimeoutError,
|
|
192
215
|
)
|
|
193
216
|
|
|
194
|
-
__version__ = "9.0
|
|
217
|
+
__version__ = "9.1.0"
|
|
195
218
|
__all__ = [
|
|
196
219
|
# Main SDK instance
|
|
197
220
|
"dominus",
|
|
@@ -267,6 +290,24 @@ __all__ = [
|
|
|
267
290
|
"verify_jwt_locally",
|
|
268
291
|
"is_jwt_valid",
|
|
269
292
|
"mint_selected_scope_jwt",
|
|
293
|
+
# Exact-target surface (lazy shared kernel codec)
|
|
294
|
+
"EXACT_TARGET_VERSION",
|
|
295
|
+
"TOPOLOGY_TARGET_VERSION",
|
|
296
|
+
"EXACT_EXCHANGE_VERSION",
|
|
297
|
+
"TARGET_HEADER",
|
|
298
|
+
"SERVICE_CORE_TAG",
|
|
299
|
+
"EXACT_TARGET_DEPENDENCY_LANE",
|
|
300
|
+
"ExactTargetError",
|
|
301
|
+
"ExactTargetDependencyError",
|
|
302
|
+
"ExactTicketRecord",
|
|
303
|
+
"validate_target",
|
|
304
|
+
"selected_scope_target",
|
|
305
|
+
"validate_target_exchange_response",
|
|
306
|
+
"assert_ticket_proves_target",
|
|
307
|
+
"canonical_target_bytes",
|
|
308
|
+
"target_header_value",
|
|
309
|
+
"target_fingerprint",
|
|
310
|
+
"topology_target_hash",
|
|
270
311
|
# Trace utilities
|
|
271
312
|
"generate_trace_id",
|
|
272
313
|
"get_current_trace_id",
|
|
@@ -108,7 +108,7 @@ def exponential_backoff_with_jitter(
|
|
|
108
108
|
"""
|
|
109
109
|
delay = min(base_delay * (2 ** attempt), max_delay)
|
|
110
110
|
jitter_range = delay * jitter
|
|
111
|
-
return delay + random.uniform(-jitter_range, jitter_range)
|
|
111
|
+
return min(max_delay, delay + random.uniform(-jitter_range, jitter_range))
|
|
112
112
|
|
|
113
113
|
|
|
114
114
|
class DominusCache:
|
|
@@ -124,7 +124,7 @@ class DominusCache:
|
|
|
124
124
|
"""
|
|
125
125
|
|
|
126
126
|
def __init__(self, default_ttl: int = 300):
|
|
127
|
-
self._store: Dict[str, Tuple[bytes, float]] = {}
|
|
127
|
+
self._store: Dict[str, Tuple[bytes, float, int]] = {}
|
|
128
128
|
self._default_ttl = default_ttl
|
|
129
129
|
self._cipher: Optional[Fernet] = None
|
|
130
130
|
|
|
@@ -141,7 +141,7 @@ class DominusCache:
|
|
|
141
141
|
if not entry:
|
|
142
142
|
return None
|
|
143
143
|
|
|
144
|
-
encrypted_value, expires_at = entry
|
|
144
|
+
encrypted_value, expires_at, duration = entry
|
|
145
145
|
|
|
146
146
|
# Check expiry
|
|
147
147
|
if time.time() >= expires_at:
|
|
@@ -159,8 +159,8 @@ class DominusCache:
|
|
|
159
159
|
else:
|
|
160
160
|
value = json.loads(encrypted_value.decode())
|
|
161
161
|
|
|
162
|
-
# Touch TTL
|
|
163
|
-
self._store[key] = (encrypted_value, time.time() +
|
|
162
|
+
# Touch TTL using the entry's own duration
|
|
163
|
+
self._store[key] = (encrypted_value, time.time() + duration, duration)
|
|
164
164
|
return value
|
|
165
165
|
|
|
166
166
|
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
|
@@ -173,7 +173,7 @@ class DominusCache:
|
|
|
173
173
|
else:
|
|
174
174
|
encrypted_value = json.dumps(value).encode()
|
|
175
175
|
|
|
176
|
-
self._store[key] = (encrypted_value, time.time() + duration)
|
|
176
|
+
self._store[key] = (encrypted_value, time.time() + duration, duration)
|
|
177
177
|
|
|
178
178
|
def delete(self, key: str) -> bool:
|
|
179
179
|
"""Delete key."""
|
|
@@ -5,6 +5,8 @@ Includes circuit breaker pattern to prevent runaway retries from exhausting
|
|
|
5
5
|
Cloud Run CPU quota during service outages or circular dependency issues.
|
|
6
6
|
"""
|
|
7
7
|
import asyncio
|
|
8
|
+
import dataclasses
|
|
9
|
+
import hashlib
|
|
8
10
|
import httpx
|
|
9
11
|
import base64
|
|
10
12
|
import json
|
|
@@ -21,6 +23,21 @@ MAX_RETRIES = 3
|
|
|
21
23
|
# When multiple concurrent coroutines need a new JWT, only one will mint
|
|
22
24
|
_jwt_refresh_lock = asyncio.Lock()
|
|
23
25
|
|
|
26
|
+
# Exact-target ticket cache. One entry is one immutable record — a ticket is
|
|
27
|
+
# never paired with target bytes derived from a different request. The key is
|
|
28
|
+
# the credential context plus the FULL target fingerprint, so any refinement
|
|
29
|
+
# (branch, tenant, provisioning target, group, environment, project) or a
|
|
30
|
+
# different credential gets its own entry.
|
|
31
|
+
EXACT_TICKET_CACHE_PREFIX = "jwt:exact:"
|
|
32
|
+
EXACT_TICKET_REFRESH_BUFFER_SECONDS = 60
|
|
33
|
+
# Upper bound on the shared encrypted cache entry; the ticket's own `exp`
|
|
34
|
+
# (minus the refresh buffer) wins when it is sooner.
|
|
35
|
+
EXACT_TICKET_CACHE_TTL = 3300 # 55 minutes
|
|
36
|
+
# One mutex per cache entry: concurrent callers for the same credential+target
|
|
37
|
+
# share a single exchange, while unrelated credentials/targets never queue
|
|
38
|
+
# behind each other's network round-trip.
|
|
39
|
+
_exact_ticket_locks: dict[str, asyncio.Lock] = {}
|
|
40
|
+
|
|
24
41
|
# Type alias
|
|
25
42
|
DominusResponse = dict[str, Any]
|
|
26
43
|
|
|
@@ -642,6 +659,249 @@ async def mint_selected_scope_jwt(
|
|
|
642
659
|
raise RuntimeError(f"Selected-scope JWT mint failed: {e}") from e
|
|
643
660
|
|
|
644
661
|
|
|
662
|
+
def _exact_credential_key(psk_token: str) -> str:
|
|
663
|
+
"""Credential discriminator for the exact-target cache. The PSK never enters a key."""
|
|
664
|
+
digest = hashlib.sha256(psk_token.encode("utf-8")).digest()
|
|
665
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _exact_refinement_key(
|
|
669
|
+
ttl_seconds: Optional[int],
|
|
670
|
+
run_id: Optional[str],
|
|
671
|
+
scopes: Optional[list],
|
|
672
|
+
) -> str:
|
|
673
|
+
"""Digest of the mint refinements — they land in the ticket's own claims."""
|
|
674
|
+
if ttl_seconds is None and run_id is None and scopes is None:
|
|
675
|
+
return ""
|
|
676
|
+
payload = {
|
|
677
|
+
"ttl_seconds": ttl_seconds,
|
|
678
|
+
"run_id": run_id,
|
|
679
|
+
"scopes": list(scopes) if scopes is not None else None,
|
|
680
|
+
}
|
|
681
|
+
digest = hashlib.sha256(
|
|
682
|
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
683
|
+
).digest()
|
|
684
|
+
return ":" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _exact_ticket_cache_key(
|
|
688
|
+
psk_token: str,
|
|
689
|
+
fingerprint: str,
|
|
690
|
+
refinements: str = "",
|
|
691
|
+
) -> str:
|
|
692
|
+
return (
|
|
693
|
+
f"{EXACT_TICKET_CACHE_PREFIX}{_exact_credential_key(psk_token)}"
|
|
694
|
+
f":{fingerprint}{refinements}"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _exact_ticket_lock_for(cache_key: str) -> asyncio.Lock:
|
|
699
|
+
lock = _exact_ticket_locks.get(cache_key)
|
|
700
|
+
if lock is None:
|
|
701
|
+
lock = asyncio.Lock()
|
|
702
|
+
_exact_ticket_locks[cache_key] = lock
|
|
703
|
+
return lock
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _exact_ticket_expiry(ticket: str) -> float:
|
|
707
|
+
"""Local expiry from the ticket's own ``exp``, refreshed before it lapses."""
|
|
708
|
+
try:
|
|
709
|
+
payload = _decode_jwt_payload(ticket)
|
|
710
|
+
exp = payload.get("exp")
|
|
711
|
+
if isinstance(exp, (int, float)) and not isinstance(exp, bool):
|
|
712
|
+
return float(exp) - EXACT_TICKET_REFRESH_BUFFER_SECONDS
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
return time.time() + EXACT_TICKET_CACHE_TTL
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _cached_exact_ticket(cache_key: str):
|
|
719
|
+
"""Read one cached record, re-validating its bytes; drop anything stale/tampered."""
|
|
720
|
+
from ..target import ExactTicketRecord
|
|
721
|
+
|
|
722
|
+
entry = dominus_cache.get(cache_key)
|
|
723
|
+
if not isinstance(entry, dict):
|
|
724
|
+
return None
|
|
725
|
+
try:
|
|
726
|
+
expires_at = float(entry.get("expires_at") or 0)
|
|
727
|
+
except (TypeError, ValueError):
|
|
728
|
+
expires_at = 0.0
|
|
729
|
+
if expires_at <= time.time():
|
|
730
|
+
dominus_cache.delete(cache_key)
|
|
731
|
+
return None
|
|
732
|
+
try:
|
|
733
|
+
return ExactTicketRecord.from_mapping(entry)
|
|
734
|
+
except Exception:
|
|
735
|
+
dominus_cache.delete(cache_key)
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _target_payload(target: Any) -> dict:
|
|
740
|
+
if dataclasses.is_dataclass(target) and not isinstance(target, type):
|
|
741
|
+
return dataclasses.asdict(target)
|
|
742
|
+
return dict(target)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
async def _exchange_exact_target(
|
|
746
|
+
psk_token: str,
|
|
747
|
+
target: Any,
|
|
748
|
+
*,
|
|
749
|
+
ttl_seconds: Optional[int] = None,
|
|
750
|
+
run_id: Optional[str] = None,
|
|
751
|
+
scopes: Optional[list] = None,
|
|
752
|
+
timeout: float = 30.0,
|
|
753
|
+
):
|
|
754
|
+
"""Perform one ``target-exchange.v1`` mint and validate the whole response."""
|
|
755
|
+
from ..config.endpoints import get_gateway_url, get_proxy_config
|
|
756
|
+
from ..errors import (
|
|
757
|
+
DominusError,
|
|
758
|
+
ConnectionError as DominusConnectionError,
|
|
759
|
+
TimeoutError as DominusTimeoutError,
|
|
760
|
+
raise_for_status,
|
|
761
|
+
)
|
|
762
|
+
from ..target import EXACT_EXCHANGE_VERSION, validate_target_exchange_response
|
|
763
|
+
from .trace import get_current_trace_id, get_current_span_id
|
|
764
|
+
|
|
765
|
+
headers = {
|
|
766
|
+
"Authorization": f"Bearer {psk_token}",
|
|
767
|
+
"Content-Type": "text/plain",
|
|
768
|
+
"X-Trace-Id": get_current_trace_id(),
|
|
769
|
+
"X-Parent-Span-Id": get_current_span_id(),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
body: dict = {"version": EXACT_EXCHANGE_VERSION, "target": _target_payload(target)}
|
|
773
|
+
if ttl_seconds is not None:
|
|
774
|
+
body["ttl_seconds"] = ttl_seconds
|
|
775
|
+
if run_id is not None:
|
|
776
|
+
body["run_id"] = run_id
|
|
777
|
+
if scopes is not None:
|
|
778
|
+
body["scopes"] = list(scopes)
|
|
779
|
+
|
|
780
|
+
try:
|
|
781
|
+
async with httpx.AsyncClient(
|
|
782
|
+
base_url=get_gateway_url(),
|
|
783
|
+
headers=headers,
|
|
784
|
+
timeout=timeout,
|
|
785
|
+
proxy=get_proxy_config(),
|
|
786
|
+
) as client:
|
|
787
|
+
response = await client.post("/jwt/mint", content=_b64_encode(body))
|
|
788
|
+
except httpx.TimeoutException as exc:
|
|
789
|
+
raise DominusTimeoutError(
|
|
790
|
+
"Exact-target exchange timed out", endpoint="/jwt/mint"
|
|
791
|
+
) from exc
|
|
792
|
+
except httpx.NetworkError as exc:
|
|
793
|
+
raise DominusConnectionError(
|
|
794
|
+
f"Exact-target exchange failed: {exc}", endpoint="/jwt/mint"
|
|
795
|
+
) from exc
|
|
796
|
+
|
|
797
|
+
if response.is_error:
|
|
798
|
+
message = f"Exact-target exchange failed: HTTP {response.status_code}"
|
|
799
|
+
details: dict = {}
|
|
800
|
+
try:
|
|
801
|
+
error_payload = _decode_json_or_b64_json(response.text)
|
|
802
|
+
except Exception:
|
|
803
|
+
error_payload = {}
|
|
804
|
+
if isinstance(error_payload, dict):
|
|
805
|
+
message = (
|
|
806
|
+
error_payload.get("message")
|
|
807
|
+
or error_payload.get("error")
|
|
808
|
+
or error_payload.get("detail")
|
|
809
|
+
or message
|
|
810
|
+
)
|
|
811
|
+
nested = error_payload.get("error")
|
|
812
|
+
error_details = error_payload.get("details")
|
|
813
|
+
details = error_details if isinstance(error_details, dict) else {}
|
|
814
|
+
for src in (error_payload, nested if isinstance(nested, dict) else {}):
|
|
815
|
+
for key in ("kind", "code", "category", "retryable", "trace_id", "request_id"):
|
|
816
|
+
if key in src and key not in details:
|
|
817
|
+
details[key] = src[key]
|
|
818
|
+
raise_for_status(response.status_code, str(message), details, "/jwt/mint")
|
|
819
|
+
|
|
820
|
+
result = _decode_json_or_b64_json(response.text)
|
|
821
|
+
if not isinstance(result, dict):
|
|
822
|
+
raise DominusError(
|
|
823
|
+
"Exact-target exchange returned a non-object response",
|
|
824
|
+
endpoint="/jwt/mint",
|
|
825
|
+
)
|
|
826
|
+
if result.get("success") is False:
|
|
827
|
+
error_details = result.get("details")
|
|
828
|
+
raise DominusError(
|
|
829
|
+
str(result.get("error") or result.get("message") or "Unknown exact-target exchange error"),
|
|
830
|
+
details=error_details if isinstance(error_details, dict) else {},
|
|
831
|
+
endpoint="/jwt/mint",
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
data = result.get("data", result)
|
|
835
|
+
return validate_target_exchange_response(data, target)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
async def mint_exact_target_ticket(
|
|
839
|
+
psk_token: str,
|
|
840
|
+
target: Any,
|
|
841
|
+
*,
|
|
842
|
+
ttl_seconds: Optional[int] = None,
|
|
843
|
+
run_id: Optional[str] = None,
|
|
844
|
+
scopes: Optional[list] = None,
|
|
845
|
+
force_refresh: bool = False,
|
|
846
|
+
timeout: float = 30.0,
|
|
847
|
+
):
|
|
848
|
+
"""Exchange an exact target for one atomic ticket record, cached.
|
|
849
|
+
|
|
850
|
+
The ticket, canonical target, fingerprint, and header are one immutable
|
|
851
|
+
unit keyed by credential context + full target fingerprint: identical
|
|
852
|
+
targets under the same credential reuse the entry, and any target-field or
|
|
853
|
+
credential change separates it.
|
|
854
|
+
"""
|
|
855
|
+
from ..target import target_fingerprint, validate_target
|
|
856
|
+
|
|
857
|
+
canonical = validate_target(target)
|
|
858
|
+
fingerprint = target_fingerprint(canonical)
|
|
859
|
+
refinements = _exact_refinement_key(ttl_seconds, run_id, scopes)
|
|
860
|
+
cache_key = _exact_ticket_cache_key(psk_token, fingerprint, refinements)
|
|
861
|
+
|
|
862
|
+
if refinements:
|
|
863
|
+
# Refinement identities are caller-controlled (run ids, TTLs) and carry
|
|
864
|
+
# per-run claims, so they are exchanged fresh instead of cached — the
|
|
865
|
+
# cache (and its lock map) stays bounded by credential × target. A
|
|
866
|
+
# ticket minted for one run can therefore never be served to another.
|
|
867
|
+
return await _exchange_exact_target(
|
|
868
|
+
psk_token,
|
|
869
|
+
canonical,
|
|
870
|
+
ttl_seconds=ttl_seconds,
|
|
871
|
+
run_id=run_id,
|
|
872
|
+
scopes=scopes,
|
|
873
|
+
timeout=timeout,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
if force_refresh:
|
|
877
|
+
dominus_cache.delete(cache_key)
|
|
878
|
+
else:
|
|
879
|
+
cached = _cached_exact_ticket(cache_key)
|
|
880
|
+
if cached is not None:
|
|
881
|
+
return cached
|
|
882
|
+
|
|
883
|
+
async with _exact_ticket_lock_for(cache_key):
|
|
884
|
+
if not force_refresh:
|
|
885
|
+
cached = _cached_exact_ticket(cache_key)
|
|
886
|
+
if cached is not None:
|
|
887
|
+
return cached
|
|
888
|
+
|
|
889
|
+
record = await _exchange_exact_target(
|
|
890
|
+
psk_token,
|
|
891
|
+
canonical,
|
|
892
|
+
ttl_seconds=ttl_seconds,
|
|
893
|
+
run_id=run_id,
|
|
894
|
+
scopes=scopes,
|
|
895
|
+
timeout=timeout,
|
|
896
|
+
)
|
|
897
|
+
expires_at = _exact_ticket_expiry(record.ticket)
|
|
898
|
+
ttl = int(max(1, min(EXACT_TICKET_CACHE_TTL, expires_at - time.time())))
|
|
899
|
+
dominus_cache.set(
|
|
900
|
+
cache_key, {**record.to_mapping(), "expires_at": expires_at}, ttl=ttl
|
|
901
|
+
)
|
|
902
|
+
return record
|
|
903
|
+
|
|
904
|
+
|
|
645
905
|
def verify_token_format(token: str) -> bool:
|
|
646
906
|
"""
|
|
647
907
|
Verify token format is valid (basic check, not server verification).
|