dominus-sdk-python 9.0.16__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.16 → dominus_sdk_python-9.1.0}/PKG-INFO +1 -1
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/__init__.py +42 -1
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/core.py +260 -0
- {dominus_sdk_python-9.0.16 → 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.16 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/PKG-INFO +1 -1
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/SOURCES.txt +2 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/pyproject.toml +16 -1
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_authority_public_vocabulary.py +73 -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.16/tests/test_public_exports.py +0 -51
- dominus_sdk_python-9.0.16/tests/test_transport_compat.py +0 -395
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/README.md +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/config/__init__.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/config/endpoints.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/contract/__init__.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/contract/envelope.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/contract/projections.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/errors.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/__init__.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/auth.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/cache.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/console_capture.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/crypto.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/sse.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/helpers/trace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/__init__.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/admin.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/ai.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/artifacts.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/auth.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/authority.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/browser.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/coder.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/courier.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/db.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/ddl.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/deployer.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/fastapi.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/files.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/health.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/jobs.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/logs.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/platform.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/portal.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/processor.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/publisher.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/recipes.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/redis.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/secrets.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/secure.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/stash.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/sync.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/usage.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/warden.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/namespaces/workflow.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/py.typed +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus/services/__init__.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/dependency_links.txt +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/requires.txt +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/dominus_sdk_python.egg-info/top_level.txt +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/setup.cfg +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_ai_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_artifacts_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_auth.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_browser_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_cache_contracts.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_contract.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_control_plane_namespaces.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_error_recovery_action.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_errors.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_errors_aliases.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_flat_commands.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_health.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_logs.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_namespace_barrel_exports.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_platform_coder_namespaces.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_provisioning_parity.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_publisher_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_readme_quickstart_catalog.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_recipes_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_recipes_stash_routing.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_secrets_presence_contract.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_stash_artifact_facade.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_stash_tables_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_token_precedence.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_usage_namespace.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_version_consistency.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_workflow_lifecycle.py +0 -0
- {dominus_sdk_python-9.0.16 → dominus_sdk_python-9.1.0}/tests/test_workflow_refs.py +0 -0
|
@@ -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",
|
|
@@ -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).
|
|
@@ -93,12 +93,18 @@ class Dominus:
|
|
|
93
93
|
gateway_org_id: Optional[str] = None,
|
|
94
94
|
gateway_app_slug: Optional[str] = None,
|
|
95
95
|
gateway_env: Optional[str] = None,
|
|
96
|
+
exact_target: Optional[Any] = None,
|
|
96
97
|
):
|
|
97
98
|
"""Initialize Dominus with flat API and minimal namespaces."""
|
|
98
99
|
self._gateway_user_token = gateway_user_token
|
|
99
100
|
self._gateway_org_id = gateway_org_id
|
|
100
101
|
self._gateway_app_slug = gateway_app_slug
|
|
101
102
|
self._gateway_env = gateway_env
|
|
103
|
+
# Default exact target for every project-scoped call; a per-call
|
|
104
|
+
# ``exact_target`` overrides it. None keeps the targetless legacy call.
|
|
105
|
+
self._default_exact_target: Optional[Any] = None
|
|
106
|
+
if exact_target is not None:
|
|
107
|
+
self.set_default_exact_target(exact_target)
|
|
102
108
|
|
|
103
109
|
# Create internal execute function that handles validation
|
|
104
110
|
async def _execute_command(command: str, **kwargs) -> Any:
|
|
@@ -238,6 +244,96 @@ class Dominus:
|
|
|
238
244
|
verify_token_format(_TOKEN)
|
|
239
245
|
_VALIDATED = True
|
|
240
246
|
|
|
247
|
+
# ========================================
|
|
248
|
+
# EXACT-TARGET TRANSPORT
|
|
249
|
+
# ========================================
|
|
250
|
+
|
|
251
|
+
def set_default_exact_target(self, exact_target: Optional[Any]) -> None:
|
|
252
|
+
"""Set (or clear) the default exact target. ``None`` = targetless legacy."""
|
|
253
|
+
if exact_target is None:
|
|
254
|
+
self._default_exact_target = None
|
|
255
|
+
return
|
|
256
|
+
from .target import validate_target
|
|
257
|
+
|
|
258
|
+
self._default_exact_target = validate_target(exact_target)
|
|
259
|
+
|
|
260
|
+
async def mint_exact_target_ticket(
|
|
261
|
+
self,
|
|
262
|
+
target: Any,
|
|
263
|
+
*,
|
|
264
|
+
ttl_seconds: Optional[int] = None,
|
|
265
|
+
run_id: Optional[str] = None,
|
|
266
|
+
scopes: Optional[List[str]] = None,
|
|
267
|
+
force_refresh: bool = False,
|
|
268
|
+
):
|
|
269
|
+
"""Exchange one exact target for an atomic ticket record (cached)."""
|
|
270
|
+
await self._ensure_ready()
|
|
271
|
+
from .helpers.core import mint_exact_target_ticket as _mint
|
|
272
|
+
|
|
273
|
+
return await _mint(
|
|
274
|
+
_TOKEN,
|
|
275
|
+
target,
|
|
276
|
+
ttl_seconds=ttl_seconds,
|
|
277
|
+
run_id=run_id,
|
|
278
|
+
scopes=scopes,
|
|
279
|
+
force_refresh=force_refresh,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async def _resolve_exact_transport(
|
|
283
|
+
self,
|
|
284
|
+
exact_target: Optional[Any],
|
|
285
|
+
user_token: Optional[str] = None,
|
|
286
|
+
) -> Optional[Any]:
|
|
287
|
+
"""Resolve the atomic ``(ticket, target_header)`` pair, or None.
|
|
288
|
+
|
|
289
|
+
A caller-supplied ``user_token`` is already the ticket, so nothing is
|
|
290
|
+
exchanged — but the target must be named on this call (a client default
|
|
291
|
+
belongs to the service identity being exchanged, never to a caller's
|
|
292
|
+
bearer) and the bearer must prove it.
|
|
293
|
+
"""
|
|
294
|
+
from .target import (
|
|
295
|
+
assert_ticket_proves_target,
|
|
296
|
+
target_fingerprint,
|
|
297
|
+
target_header_value,
|
|
298
|
+
validate_target,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if user_token:
|
|
302
|
+
if exact_target is None:
|
|
303
|
+
return None
|
|
304
|
+
canonical = validate_target(exact_target)
|
|
305
|
+
assert_ticket_proves_target(
|
|
306
|
+
user_token, target_fingerprint(canonical)
|
|
307
|
+
)
|
|
308
|
+
return user_token, target_header_value(canonical)
|
|
309
|
+
|
|
310
|
+
target = (
|
|
311
|
+
exact_target if exact_target is not None else self._default_exact_target
|
|
312
|
+
)
|
|
313
|
+
if target is None:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# The exchange authenticates with the project PSK, so the exact path
|
|
317
|
+
# enforces the same token presence/format check as the targetless path.
|
|
318
|
+
await self._ensure_ready()
|
|
319
|
+
from .helpers.core import mint_exact_target_ticket
|
|
320
|
+
|
|
321
|
+
record = await mint_exact_target_ticket(_TOKEN, target)
|
|
322
|
+
return record.ticket, record.target_header
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _apply_exact_pair(
|
|
326
|
+
headers: Dict[str, str], exact: Optional[Any]
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Write the atomic pair last so no caller header can split it."""
|
|
329
|
+
if not exact:
|
|
330
|
+
return
|
|
331
|
+
from .target import TARGET_HEADER
|
|
332
|
+
|
|
333
|
+
ticket, target_header = exact
|
|
334
|
+
headers["Authorization"] = f"Bearer {ticket}"
|
|
335
|
+
headers[TARGET_HEADER] = target_header
|
|
336
|
+
|
|
241
337
|
@staticmethod
|
|
242
338
|
def _normalize_flat_schema(schema: Optional[str]) -> str:
|
|
243
339
|
"""Map legacy flat schema names onto the live gateway contract."""
|
|
@@ -351,6 +447,7 @@ class Dominus:
|
|
|
351
447
|
timeout: float = 30.0,
|
|
352
448
|
actor: Optional[Dict[str, str]] = None,
|
|
353
449
|
extra_headers: Optional[Dict[str, str]] = None,
|
|
450
|
+
exact_target: Optional[Any] = None,
|
|
354
451
|
) -> Dict[str, Any]:
|
|
355
452
|
"""
|
|
356
453
|
Make an HTTP request to the Dominus service plane.
|
|
@@ -380,7 +477,11 @@ class Dominus:
|
|
|
380
477
|
|
|
381
478
|
# Determine which JWT to use (per-call user_token wins over MCP/session client defaults)
|
|
382
479
|
effective_user_token = user_token or getattr(self, "_gateway_user_token", None)
|
|
383
|
-
|
|
480
|
+
exact = await self._resolve_exact_transport(exact_target, effective_user_token)
|
|
481
|
+
if exact:
|
|
482
|
+
# The exchanged ticket IS the bearer; the header travels with it.
|
|
483
|
+
jwt = exact[0]
|
|
484
|
+
elif effective_user_token:
|
|
384
485
|
# Use the provided user JWT directly; downstream services validate it.
|
|
385
486
|
jwt = effective_user_token
|
|
386
487
|
else:
|
|
@@ -424,6 +525,8 @@ class Dominus:
|
|
|
424
525
|
if effective_actor and effective_actor.get("type") and effective_actor.get("id"):
|
|
425
526
|
headers["X-Actor-Type"] = str(effective_actor["type"])
|
|
426
527
|
headers["X-Actor-Id"] = str(effective_actor["id"])
|
|
528
|
+
# Atomic exact pair, written last: no caller header can split ticket/bytes.
|
|
529
|
+
self._apply_exact_pair(headers, exact)
|
|
427
530
|
|
|
428
531
|
# Prepare body
|
|
429
532
|
body_b64 = _b64_encode(body or {})
|
|
@@ -527,15 +630,20 @@ class Dominus:
|
|
|
527
630
|
body: Optional[Dict[str, Any]] = None,
|
|
528
631
|
headers: Optional[Dict[str, str]] = None,
|
|
529
632
|
timeout: float = 30.0,
|
|
633
|
+
exact_target: Optional[Any] = None,
|
|
530
634
|
) -> Any:
|
|
531
635
|
"""Gateway `/svc/*` fetch with mixed JSON/base64 wire handling.
|
|
532
636
|
|
|
533
637
|
Mirrors the Node SDK `DominusClient.gatewayFetch()` contract so Python
|
|
534
638
|
callers can use the same remaining operator control-plane surfaces.
|
|
535
639
|
"""
|
|
536
|
-
await self.
|
|
537
|
-
|
|
538
|
-
|
|
640
|
+
exact = await self._resolve_exact_transport(exact_target)
|
|
641
|
+
if exact:
|
|
642
|
+
jwt = exact[0]
|
|
643
|
+
else:
|
|
644
|
+
await self._ensure_ready()
|
|
645
|
+
from .helpers.core import _ensure_valid_jwt
|
|
646
|
+
jwt = await _ensure_valid_jwt(_TOKEN, _BASE_URL)
|
|
539
647
|
|
|
540
648
|
path = path.strip()
|
|
541
649
|
if not path.startswith("/"):
|
|
@@ -568,6 +676,7 @@ class Dominus:
|
|
|
568
676
|
}
|
|
569
677
|
if headers:
|
|
570
678
|
req_headers.update(headers)
|
|
679
|
+
self._apply_exact_pair(req_headers, exact)
|
|
571
680
|
|
|
572
681
|
request_content = None
|
|
573
682
|
request_json = None
|
|
@@ -655,7 +764,8 @@ class Dominus:
|
|
|
655
764
|
body: Optional[Dict[str, Any]] = None,
|
|
656
765
|
on_chunk: Optional[Any] = None,
|
|
657
766
|
timeout: float = 300.0,
|
|
658
|
-
use_gateway: bool = True
|
|
767
|
+
use_gateway: bool = True,
|
|
768
|
+
exact_target: Optional[Any] = None,
|
|
659
769
|
):
|
|
660
770
|
"""
|
|
661
771
|
Make an SSE streaming request to the Dominus service plane.
|
|
@@ -679,9 +789,13 @@ class Dominus:
|
|
|
679
789
|
endpoint = "/svc/" + endpoint[5:]
|
|
680
790
|
|
|
681
791
|
# Validate and get JWT
|
|
682
|
-
await self.
|
|
683
|
-
|
|
684
|
-
|
|
792
|
+
exact = await self._resolve_exact_transport(exact_target)
|
|
793
|
+
if exact:
|
|
794
|
+
jwt = exact[0]
|
|
795
|
+
else:
|
|
796
|
+
await self._ensure_ready()
|
|
797
|
+
from .helpers.core import _ensure_valid_jwt
|
|
798
|
+
jwt = await _ensure_valid_jwt(_TOKEN, _BASE_URL)
|
|
685
799
|
|
|
686
800
|
from .helpers.trace import get_current_trace_id, get_current_span_id
|
|
687
801
|
headers = {
|
|
@@ -691,6 +805,7 @@ class Dominus:
|
|
|
691
805
|
"X-Trace-Id": get_current_trace_id(),
|
|
692
806
|
"X-Parent-Span-Id": get_current_span_id(),
|
|
693
807
|
}
|
|
808
|
+
self._apply_exact_pair(headers, exact)
|
|
694
809
|
|
|
695
810
|
body_b64 = _b64_encode(body or {})
|
|
696
811
|
|
|
@@ -711,7 +826,8 @@ class Dominus:
|
|
|
711
826
|
content_type: str = "application/octet-stream",
|
|
712
827
|
additional_fields: Optional[Dict[str, str]] = None,
|
|
713
828
|
timeout: float = 60.0,
|
|
714
|
-
use_gateway: bool = True
|
|
829
|
+
use_gateway: bool = True,
|
|
830
|
+
exact_target: Optional[Any] = None,
|
|
715
831
|
) -> Dict[str, Any]:
|
|
716
832
|
"""
|
|
717
833
|
Upload binary file via multipart/form-data (no base64 encoding).
|
|
@@ -734,9 +850,13 @@ class Dominus:
|
|
|
734
850
|
if use_gateway and endpoint.startswith("/api/"):
|
|
735
851
|
endpoint = "/svc/" + endpoint[5:]
|
|
736
852
|
|
|
737
|
-
await self.
|
|
738
|
-
|
|
739
|
-
|
|
853
|
+
exact = await self._resolve_exact_transport(exact_target)
|
|
854
|
+
if exact:
|
|
855
|
+
jwt = exact[0]
|
|
856
|
+
else:
|
|
857
|
+
await self._ensure_ready()
|
|
858
|
+
from .helpers.core import _ensure_valid_jwt
|
|
859
|
+
jwt = await _ensure_valid_jwt(_TOKEN, _BASE_URL)
|
|
740
860
|
|
|
741
861
|
from .helpers.trace import get_current_trace_id, get_current_span_id
|
|
742
862
|
headers = {
|
|
@@ -744,6 +864,7 @@ class Dominus:
|
|
|
744
864
|
"X-Trace-Id": get_current_trace_id(),
|
|
745
865
|
"X-Parent-Span-Id": get_current_span_id(),
|
|
746
866
|
}
|
|
867
|
+
self._apply_exact_pair(headers, exact)
|
|
747
868
|
|
|
748
869
|
# Build multipart form data
|
|
749
870
|
files = {"file": (filename, file_bytes, content_type)}
|
|
@@ -770,7 +891,8 @@ class Dominus:
|
|
|
770
891
|
endpoint: str,
|
|
771
892
|
body: Optional[Dict[str, Any]] = None,
|
|
772
893
|
timeout: float = 60.0,
|
|
773
|
-
use_gateway: bool = True
|
|
894
|
+
use_gateway: bool = True,
|
|
895
|
+
exact_target: Optional[Any] = None,
|
|
774
896
|
) -> bytes:
|
|
775
897
|
"""
|
|
776
898
|
Download binary content (no base64 encoding in response).
|
|
@@ -793,9 +915,13 @@ class Dominus:
|
|
|
793
915
|
if use_gateway and endpoint.startswith("/api/"):
|
|
794
916
|
endpoint = "/svc/" + endpoint[5:]
|
|
795
917
|
|
|
796
|
-
await self.
|
|
797
|
-
|
|
798
|
-
|
|
918
|
+
exact = await self._resolve_exact_transport(exact_target)
|
|
919
|
+
if exact:
|
|
920
|
+
jwt = exact[0]
|
|
921
|
+
else:
|
|
922
|
+
await self._ensure_ready()
|
|
923
|
+
from .helpers.core import _ensure_valid_jwt
|
|
924
|
+
jwt = await _ensure_valid_jwt(_TOKEN, _BASE_URL)
|
|
799
925
|
|
|
800
926
|
from .helpers.trace import get_current_trace_id, get_current_span_id
|
|
801
927
|
headers = {
|
|
@@ -805,6 +931,7 @@ class Dominus:
|
|
|
805
931
|
"X-Trace-Id": get_current_trace_id(),
|
|
806
932
|
"X-Parent-Span-Id": get_current_span_id(),
|
|
807
933
|
}
|
|
934
|
+
self._apply_exact_pair(headers, exact)
|
|
808
935
|
|
|
809
936
|
body_b64 = _b64_encode(body or {})
|
|
810
937
|
|