dominus-sdk-python-staging 2.1.6__py3-none-any.whl

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.
@@ -0,0 +1,657 @@
1
+ """
2
+ Core HTTP client, routing, and retry logic.
3
+
4
+ Includes circuit breaker pattern to prevent runaway retries from exhausting
5
+ Cloud Run CPU quota during service outages or circular dependency issues.
6
+ """
7
+ import asyncio
8
+ import httpx
9
+ import base64
10
+ import json
11
+ import time
12
+ from typing import Any, Optional
13
+
14
+ from .cache import dominus_cache, sovereign_circuit_breaker, exponential_backoff_with_jitter
15
+
16
+ # Max retries for HTTP requests (reduced from implicit to explicit)
17
+ MAX_RETRIES = 3
18
+
19
+ # Type alias
20
+ DominusResponse = dict[str, Any]
21
+
22
+ # Base64 helpers
23
+ # Encode: Convert dict to JSON, then to raw base64 string (matches middleware expectation)
24
+ _b64_encode = lambda d: base64.b64encode(json.dumps(d).encode()).decode()
25
+ # Decode: Decode raw base64 string to JSON, then parse (matches middleware response format)
26
+ _b64_decode = lambda s: json.loads(base64.b64decode(s.encode('utf-8')).decode('utf-8')) if isinstance(s, str) else s
27
+ _b64_token = lambda t: base64.b64encode(t.encode()).decode()
28
+
29
+ # Base64url helpers for JWT
30
+ def _b64url_decode(s: str) -> bytes:
31
+ """Decode base64url string (JWT uses base64url, not base64)."""
32
+ # Add padding if needed
33
+ padding = 4 - len(s) % 4
34
+ if padding != 4:
35
+ s += '=' * padding
36
+ # Replace URL-safe characters
37
+ s = s.replace('-', '+').replace('_', '/')
38
+ return base64.b64decode(s)
39
+
40
+
41
+ def _decode_jwt_payload(jwt: str) -> dict:
42
+ """
43
+ Decode JWT payload without verification (for exp checks only).
44
+
45
+ Args:
46
+ jwt: JWT token string
47
+
48
+ Returns:
49
+ Decoded payload dict
50
+ """
51
+ try:
52
+ parts = jwt.split('.')
53
+ if len(parts) != 3:
54
+ raise ValueError("Invalid JWT format")
55
+ payload_b64url = parts[1]
56
+ payload_bytes = _b64url_decode(payload_b64url)
57
+ return json.loads(payload_bytes.decode('utf-8'))
58
+ except Exception as e:
59
+ raise ValueError(f"Failed to decode JWT payload: {e}")
60
+
61
+
62
+ async def _get_service_jwt(psk_token: str, base_url: str) -> str:
63
+ """
64
+ Get service JWT by calling /api/warden/mint with PSK.
65
+
66
+ Uses circuit breaker to prevent retry storms during service outages.
67
+ Retries on 401/5xx with exponential backoff (orchestrator cold start handling).
68
+
69
+ Args:
70
+ psk_token: PSK token (DOMINUS_TOKEN)
71
+ base_url: Orchestrator base URL
72
+
73
+ Returns:
74
+ JWT token string
75
+
76
+ Raises:
77
+ RuntimeError: If circuit is open or auth fails after retries
78
+ """
79
+ # Circuit breaker check
80
+ if not sovereign_circuit_breaker.can_execute():
81
+ raise RuntimeError(
82
+ f"Circuit breaker OPEN for auth - "
83
+ f"too many recent failures. Will retry after recovery timeout."
84
+ )
85
+
86
+ if sovereign_circuit_breaker.state == sovereign_circuit_breaker.HALF_OPEN:
87
+ sovereign_circuit_breaker.record_half_open_call()
88
+
89
+ headers = {
90
+ "Authorization": f"Bearer {psk_token}",
91
+ "Content-Type": "text/plain"
92
+ }
93
+
94
+ # Body format for warden/mint endpoint
95
+ body_json = {"method": "auth.self", "params": {}}
96
+ body_b64 = _b64_encode(body_json)
97
+
98
+ # Retry loop for JWT minting (handles orchestrator cold start)
99
+ JWT_MINT_RETRIES = 3
100
+ last_error = None
101
+
102
+ for attempt in range(JWT_MINT_RETRIES):
103
+ try:
104
+ async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
105
+ response = await client.post("/api/warden/mint", content=body_b64)
106
+
107
+ # Check for retryable status codes before raise_for_status
108
+ if response.status_code == 401 or response.status_code >= 500:
109
+ if attempt < JWT_MINT_RETRIES - 1:
110
+ delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
111
+ print(
112
+ f"[Dominus] JWT mint returned {response.status_code}, "
113
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
114
+ )
115
+ await asyncio.sleep(delay)
116
+ continue
117
+
118
+ response.raise_for_status()
119
+
120
+ # Decode base64 response
121
+ result = _b64_decode(response.text)
122
+
123
+ if not result.get("success"):
124
+ error_msg = result.get("error", "Unknown auth error")
125
+ sovereign_circuit_breaker.record_failure()
126
+ raise RuntimeError(f"Auth error: {error_msg}")
127
+
128
+ data = result.get("data", {})
129
+ jwt = data.get("access_token") or data.get("token")
130
+ if not jwt:
131
+ sovereign_circuit_breaker.record_failure()
132
+ raise RuntimeError("No JWT token in auth response")
133
+
134
+ # Success - record it
135
+ sovereign_circuit_breaker.record_success()
136
+ return jwt
137
+
138
+ except httpx.TimeoutException as e:
139
+ last_error = e
140
+ if attempt < JWT_MINT_RETRIES - 1:
141
+ delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
142
+ print(
143
+ f"[Dominus] JWT mint timed out, "
144
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
145
+ )
146
+ await asyncio.sleep(delay)
147
+ continue
148
+ sovereign_circuit_breaker.record_failure()
149
+ raise RuntimeError(f"Failed to get JWT: {e}") from e
150
+
151
+ except httpx.NetworkError as e:
152
+ last_error = e
153
+ if attempt < JWT_MINT_RETRIES - 1:
154
+ delay = exponential_backoff_with_jitter(attempt, base_delay=2.0, max_delay=10.0)
155
+ print(
156
+ f"[Dominus] JWT mint network error ({e}), "
157
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{JWT_MINT_RETRIES})"
158
+ )
159
+ await asyncio.sleep(delay)
160
+ continue
161
+ sovereign_circuit_breaker.record_failure()
162
+ raise RuntimeError(f"Failed to get JWT: {e}") from e
163
+
164
+ except httpx.HTTPStatusError as e:
165
+ last_error = e
166
+ # 4xx errors (except 401 which is retried above) should not be retried
167
+ if 400 <= e.response.status_code < 500 and e.response.status_code != 401:
168
+ sovereign_circuit_breaker.record_failure()
169
+ raise RuntimeError(f"Failed to get JWT: {e}") from e
170
+ # For other errors, we've already handled retries in the status check above
171
+ sovereign_circuit_breaker.record_failure()
172
+ raise RuntimeError(f"Failed to get JWT: {e}") from e
173
+
174
+ # Should not reach here, but just in case
175
+ sovereign_circuit_breaker.record_failure()
176
+ raise RuntimeError(f"Failed to get JWT after {JWT_MINT_RETRIES} retries: {last_error}")
177
+
178
+
179
+ def _get_architect_url(psk_token: str = None, sovereign_url: str = None, environment: str = None) -> str:
180
+ """
181
+ Get Architect Cloud URL from SDK config.
182
+
183
+ Uses flat file config (dominus/config/endpoints.py) for URL lookup.
184
+ No network calls needed - URLs are hardcoded constants.
185
+
186
+ Args:
187
+ psk_token: Unused (kept for backward compatibility)
188
+ sovereign_url: Unused (kept for backward compatibility)
189
+ environment: Environment override (development, staging, production).
190
+ Defaults to CB_ENVIRONMENT env var or "production".
191
+
192
+ Returns:
193
+ Architect base URL string
194
+ """
195
+ from ..config.endpoints import get_architect_url
196
+ return get_architect_url(environment)
197
+
198
+
199
+ async def _ensure_valid_jwt(psk_token: str, sovereign_url: str) -> str:
200
+ """
201
+ Ensure we have a valid JWT, fetching and caching if needed.
202
+
203
+ Cache key: "jwt:self:service"
204
+ Cache TTL: 14 minutes (JWT lifetime is 15 minutes)
205
+ Refresh when <60 seconds remain.
206
+
207
+ Args:
208
+ psk_token: PSK token (DOMINUS_TOKEN)
209
+ sovereign_url: Sovereign base URL
210
+
211
+ Returns:
212
+ Valid JWT token string
213
+ """
214
+ cache_key = "jwt:self:service"
215
+
216
+ # Check cache
217
+ cached_jwt = dominus_cache.get(cache_key)
218
+ if cached_jwt:
219
+ try:
220
+ # Decode payload to check expiry
221
+ payload = _decode_jwt_payload(cached_jwt)
222
+ exp = payload.get("exp", 0)
223
+ current_time = int(time.time())
224
+
225
+ # Refresh if <60 seconds remain
226
+ if exp - current_time > 60:
227
+ return cached_jwt
228
+ except Exception:
229
+ # If decode fails, fetch new JWT
230
+ pass
231
+
232
+ # Fetch new JWT
233
+ jwt = await _get_service_jwt(psk_token, sovereign_url)
234
+
235
+ # Cache for 14 minutes (840 seconds)
236
+ dominus_cache.set(cache_key, jwt, ttl=840)
237
+
238
+ return jwt
239
+
240
+
241
+ def verify_token_format(token: str) -> bool:
242
+ """
243
+ Verify token format is valid (basic check, not server verification).
244
+ Actual token validation happens when we call /api/warden/mint.
245
+
246
+ Args:
247
+ token: Auth token to verify
248
+
249
+ Returns:
250
+ True if token format is valid
251
+
252
+ Raises:
253
+ RuntimeError: If token format is invalid
254
+ """
255
+ # PSK tokens are 64-character hex strings
256
+ if not token or len(token) < 32:
257
+ raise RuntimeError("Invalid token format")
258
+ return True
259
+
260
+
261
+ async def health_check_all(base_url: str) -> dict:
262
+ """
263
+ Check health of orchestrator services.
264
+
265
+ Args:
266
+ base_url: Orchestrator base URL
267
+
268
+ Returns:
269
+ Health status dict with service results
270
+ """
271
+ results = {}
272
+
273
+ # Check orchestrator via /api/health
274
+ try:
275
+ start = time.time()
276
+ async with httpx.AsyncClient(base_url=base_url, timeout=5.0) as client:
277
+ response = await client.get("/api/health")
278
+ response.raise_for_status()
279
+
280
+ # Parse response (may be JSON string or dict)
281
+ health_data = response.json() if response.headers.get("content-type", "").startswith("application/json") else {"status": "ok"}
282
+
283
+ latency = int((time.time() - start) * 1000)
284
+ results["orchestrator"] = {
285
+ "status": health_data.get("status", "healthy"),
286
+ "latency_ms": latency,
287
+ **health_data
288
+ }
289
+ except Exception as e:
290
+ results["orchestrator"] = {"status": "unhealthy", "error": str(e)}
291
+ return {"status": "unhealthy", "services": results, "message": "Orchestrator unhealthy"}
292
+
293
+ return {"status": "healthy", "services": results, "message": "All services healthy"}
294
+
295
+
296
+ async def get_service_url(service_name: str, token: str, sovereign_url: str) -> str:
297
+ """
298
+ Get service base URL. Cache-first, then asks sovereign.
299
+
300
+ Args:
301
+ service_name: "architect", "notifier", etc.
302
+ token: Auth token (for sovereign API call)
303
+ sovereign_url: Sovereign base URL
304
+
305
+ Returns:
306
+ Base URL for the service
307
+ """
308
+ cache_key = f"service_url:{service_name}"
309
+
310
+ # Check cache first
311
+ cached = dominus_cache.get(cache_key)
312
+ if cached:
313
+ return cached
314
+
315
+ # Ask sovereign (future endpoint)
316
+ # TODO: Create endpoint in sovereign to return service URLs
317
+ # For now, construct from pattern
318
+ url = f"https://{service_name}-cloud-production-775398158805.us-east4.run.app"
319
+
320
+ # Cache with long TTL (URLs don't change often)
321
+ dominus_cache.set(cache_key, url, ttl=3600) # 1 hour
322
+
323
+ return url
324
+
325
+
326
+ async def execute_with_retry(
327
+ route_info: tuple[str, str, bool, bool],
328
+ base_url: str,
329
+ token: str | None,
330
+ kwargs: dict
331
+ ) -> DominusResponse:
332
+ """
333
+ Execute HTTP request with retry logic.
334
+
335
+ Args:
336
+ route_info: (method, path, requires_auth, cacheable)
337
+ base_url: Base URL for API
338
+ token: Auth token (if needed)
339
+ kwargs: Request parameters
340
+
341
+ Returns:
342
+ Response dict
343
+ """
344
+ method, path, requires_auth, cacheable = route_info
345
+
346
+ # Check cache first
347
+ if cacheable and kwargs:
348
+ cache_key = f"{path}:{str(sorted(kwargs.items()))}"
349
+ cached = dominus_cache.get(cache_key)
350
+ if cached:
351
+ return cached
352
+
353
+ # Validate token
354
+ if requires_auth and not token:
355
+ raise RuntimeError(
356
+ "DOMINUS_TOKEN not set. "
357
+ "Set the environment variable: export DOMINUS_TOKEN=your_token"
358
+ )
359
+
360
+ # Retry loop with exponential backoff and jitter
361
+ for attempt in range(MAX_RETRIES):
362
+ try:
363
+ # Prepare headers
364
+ headers = {}
365
+ if requires_auth:
366
+ headers["Authorization"] = f"Bearer {_b64_token(token)}"
367
+
368
+ # Prepare body (encode if auth required and kwargs provided)
369
+ # For auth routes: send raw base64 string as text/plain (matches middleware expectation)
370
+ # For non-auth routes: send JSON as normal
371
+ if requires_auth and kwargs:
372
+ body_b64 = _b64_encode(kwargs)
373
+ headers["Content-Type"] = "text/plain"
374
+ body = body_b64
375
+ else:
376
+ body = kwargs if kwargs else {}
377
+
378
+ # Make request
379
+ async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
380
+ if method == "GET":
381
+ response = await client.get(path, params=kwargs if kwargs else None)
382
+ else:
383
+ if requires_auth and kwargs:
384
+ # Send raw base64 string as text
385
+ response = await client.post(path, content=body)
386
+ else:
387
+ # Send JSON as normal
388
+ response = await client.post(path, json=body if body else {})
389
+
390
+ response.raise_for_status()
391
+
392
+ # Decode response
393
+ # For auth routes: middleware returns raw base64 string as text/plain
394
+ # For non-auth routes: normal JSON response
395
+ if requires_auth:
396
+ # Read response as text (base64 string), then decode
397
+ result = _b64_decode(response.text)
398
+ else:
399
+ result = response.json()
400
+
401
+ # Cache if needed (encrypted automatically)
402
+ if cacheable and kwargs:
403
+ dominus_cache.set(cache_key, result, ttl=300)
404
+
405
+ return result
406
+
407
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
408
+ if attempt == MAX_RETRIES - 1: # Last attempt
409
+ raise RuntimeError(f"Request failed after {MAX_RETRIES} retries: {e}") from e
410
+ delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
411
+ await asyncio.sleep(delay)
412
+
413
+ except httpx.HTTPStatusError as e:
414
+ # Don't retry 4xx errors (client errors)
415
+ if 400 <= e.response.status_code < 500:
416
+ # Try to decode error response for better error messages
417
+ error_detail = f"HTTP {e.response.status_code}"
418
+ try:
419
+ if requires_auth:
420
+ # Auth routes return base64-encoded responses
421
+ error_body = _b64_decode(e.response.text)
422
+ if isinstance(error_body, dict):
423
+ error_detail = error_body.get("error") or error_body.get("detail") or str(error_body)
424
+ else:
425
+ error_detail = str(error_body)
426
+ else:
427
+ # Non-auth routes return JSON
428
+ error_body = e.response.json()
429
+ error_detail = error_body.get("error") or error_body.get("detail") or str(error_body)
430
+ except Exception:
431
+ # If decoding fails, use response text as-is
432
+ error_detail = e.response.text[:200] if e.response.text else str(e)
433
+
434
+ raise RuntimeError(f"{error_detail} (status {e.response.status_code})") from e
435
+ if attempt == 2: # Last attempt
436
+ raise
437
+ await asyncio.sleep(2 ** attempt)
438
+
439
+
440
+ async def execute_bridge_call(
441
+ method: str,
442
+ base_url: str,
443
+ token: str,
444
+ params: dict,
445
+ cacheable: bool = False,
446
+ endpoint: str = "/newapi/bridge"
447
+ ) -> DominusResponse:
448
+ """
449
+ Execute a call to the Bridge API (or crossover endpoint).
450
+
451
+ The Bridge API uses:
452
+ - JWT token in Authorization header (obtained via _ensure_valid_jwt)
453
+ - Base64-encoded JSON body: {"method": "...", "params": {...}}
454
+ - Base64-encoded JSON response: {"success": true, "data": {...}}
455
+
456
+ Args:
457
+ method: Bridge method name (e.g., "secrets.get")
458
+ base_url: Base URL for sovereign
459
+ token: PSK token (used to get JWT via _ensure_valid_jwt)
460
+ params: Method parameters
461
+ cacheable: Whether to cache the result
462
+ endpoint: API endpoint path (default: "/newapi/bridge", can be "/newapi/crossover")
463
+
464
+ Returns:
465
+ Response data (the "data" field from successful response)
466
+ """
467
+ # Check cache first
468
+ if cacheable and params:
469
+ cache_key = f"bridge:{method}:{str(sorted(params.items()))}"
470
+ cached = dominus_cache.get(cache_key)
471
+ if cached:
472
+ return cached
473
+
474
+ if not token:
475
+ raise RuntimeError(
476
+ "DOMINUS_TOKEN not set. "
477
+ "Set the environment variable: export DOMINUS_TOKEN=your_token"
478
+ )
479
+
480
+ # Get JWT for Bridge/Crossover calls
481
+ jwt = await _ensure_valid_jwt(token, base_url)
482
+
483
+ # Prepare Bridge API request with JWT
484
+ headers = {
485
+ "Authorization": f"Bearer {jwt}",
486
+ "Content-Type": "text/plain"
487
+ }
488
+
489
+ # Body format: {"method": "...", "params": {...}}
490
+ body_json = {"method": method, "params": params}
491
+ body_b64 = _b64_encode(body_json)
492
+
493
+ # Retry loop with exponential backoff and jitter
494
+ for attempt in range(MAX_RETRIES):
495
+ try:
496
+ async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
497
+ response = await client.post(endpoint, content=body_b64)
498
+
499
+ response.raise_for_status()
500
+
501
+ # Decode base64 response
502
+ result = _b64_decode(response.text)
503
+
504
+ # Bridge API returns {"success": true, "data": {...}} or {"success": false, "error": "..."}
505
+ if not result.get("success"):
506
+ error_msg = result.get("error", "Unknown bridge error")
507
+ raise RuntimeError(f"Bridge error: {error_msg}")
508
+
509
+ data = result.get("data", {})
510
+
511
+ # Cache if needed
512
+ if cacheable and params:
513
+ dominus_cache.set(cache_key, data, ttl=300)
514
+
515
+ return data
516
+
517
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
518
+ if attempt == MAX_RETRIES - 1:
519
+ raise RuntimeError(f"Bridge call failed after {MAX_RETRIES} retries: {e}") from e
520
+ delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
521
+ await asyncio.sleep(delay)
522
+
523
+ except httpx.HTTPStatusError as e:
524
+ # Don't retry 4xx errors
525
+ if 400 <= e.response.status_code < 500:
526
+ error_detail = f"HTTP {e.response.status_code}"
527
+ try:
528
+ error_body = _b64_decode(e.response.text)
529
+ if isinstance(error_body, dict):
530
+ error_detail = error_body.get("error") or str(error_body)
531
+ else:
532
+ error_detail = str(error_body)
533
+ except Exception:
534
+ error_detail = e.response.text[:200] if e.response.text else str(e)
535
+
536
+ raise RuntimeError(f"{error_detail} (status {e.response.status_code})") from e
537
+ if attempt == MAX_RETRIES - 1:
538
+ raise RuntimeError(f"Bridge call failed after {MAX_RETRIES} retries: {e}") from e
539
+ delay = exponential_backoff_with_jitter(attempt, base_delay=1.0, max_delay=15.0)
540
+ await asyncio.sleep(delay)
541
+
542
+
543
+ async def execute_command(command: str, token: str, sovereign_url: str, **kwargs) -> DominusResponse:
544
+ """
545
+ Execute a command by routing to the appropriate service handler.
546
+
547
+ Args:
548
+ command: Command string (e.g., "secrets.get", "health.check", "auth.self")
549
+ token: PSK auth token (DOMINUS_TOKEN)
550
+ sovereign_url: Sovereign base URL
551
+ **kwargs: Command parameters
552
+
553
+ Returns:
554
+ Response dict
555
+ """
556
+ # Special command: health.check
557
+ if command == "health.check":
558
+ return await health_check_all(sovereign_url)
559
+
560
+ # Special command: auth.self (get service JWT)
561
+ if command == "auth.self":
562
+ jwt = await _ensure_valid_jwt(token, sovereign_url)
563
+ return {"access_token": jwt, "token_type": "bearer"}
564
+
565
+ # Route Sovereign auth commands to /newapi/auth
566
+ # auth.jwks is public (no PSK required), auth.mint requires PSK
567
+ if command == "auth.jwks":
568
+ return await _execute_auth_call(command, None, sovereign_url, **kwargs)
569
+ elif command == "auth.mint":
570
+ return await _execute_auth_call(command, token, sovereign_url, **kwargs)
571
+
572
+ # Route sql.* commands to Architect
573
+ if command.startswith("sql."):
574
+ from ..services import architect
575
+ architect_url = _get_architect_url(token, sovereign_url)
576
+ return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
577
+
578
+ # Route auth.* commands to Architect (subsidiary auth management)
579
+ # Note: auth.jwks and auth.mint are handled above (Sovereign)
580
+ if command.startswith("auth."):
581
+ from ..services import architect
582
+ architect_url = _get_architect_url(token, sovereign_url)
583
+ return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
584
+
585
+ # Route schema.* commands to Architect
586
+ if command.startswith("schema."):
587
+ from ..services import architect
588
+ architect_url = _get_architect_url(token, sovereign_url)
589
+ return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
590
+
591
+ # Route crossover.sql.* to Architect Crossover
592
+ if command.startswith("crossover.sql."):
593
+ from ..services import architect
594
+ architect_url = _get_architect_url(token, sovereign_url)
595
+ return await architect.handle(command, token, architect_url, sovereign_url, **kwargs)
596
+
597
+ # Route crossover.* (non-sql) to Sovereign Crossover
598
+ if command.startswith("crossover."):
599
+ from ..services import sovereign
600
+ return await sovereign.handle(command, token, sovereign_url, **kwargs)
601
+
602
+ # Default to Sovereign
603
+ from ..services import sovereign
604
+ return await sovereign.handle(command, token, sovereign_url, **kwargs)
605
+
606
+
607
+ async def _execute_auth_call(
608
+ method: str,
609
+ psk_token: Optional[str],
610
+ base_url: str,
611
+ **kwargs
612
+ ) -> DominusResponse:
613
+ """
614
+ Execute an auth call to /api/warden/* endpoints.
615
+
616
+ Args:
617
+ method: Auth method name (e.g., "auth.mint", "auth.jwks")
618
+ psk_token: PSK token (DOMINUS_TOKEN) - None for public endpoints like auth.jwks
619
+ base_url: Orchestrator base URL
620
+ **kwargs: Method parameters
621
+
622
+ Returns:
623
+ Response data
624
+ """
625
+ headers = {
626
+ "Content-Type": "text/plain"
627
+ }
628
+
629
+ # Add PSK header only if token provided (auth.jwks is public)
630
+ if psk_token:
631
+ headers["Authorization"] = f"Bearer {psk_token}"
632
+
633
+ # Body format: {"method": "...", "params": {...}}
634
+ body_json = {"method": method, "params": kwargs}
635
+ body_b64 = _b64_encode(body_json)
636
+
637
+ # Map method to endpoint
638
+ if method == "auth.jwks":
639
+ endpoint = "/api/warden/jwks"
640
+ elif method == "auth.mint":
641
+ endpoint = "/api/warden/mint"
642
+ else:
643
+ endpoint = "/api/warden/mint"
644
+
645
+ async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=30.0) as client:
646
+ response = await client.post(endpoint, content=body_b64)
647
+ response.raise_for_status()
648
+
649
+ # Decode base64 response
650
+ result = _b64_decode(response.text)
651
+
652
+ if not result.get("success"):
653
+ error_msg = result.get("error", "Unknown auth error")
654
+ raise RuntimeError(f"Auth error: {error_msg}")
655
+
656
+ return result.get("data", {})
657
+