"""Clients for the executor's owners (NK-WP-0056-T13): approval-engine (carrier) and audit-core (sink). Stdlib HTTP only. Bearer tokens are read from files at call time (projected or ESO-delivered), never logged. Errors carry status codes only, never response bodies. """ from __future__ import annotations import json import pathlib import ssl import urllib.error import urllib.parse import urllib.request class ClientError(Exception): pass class _Http: def __init__(self, base: str, token_file: str, ca_file: str | None = None, timeout: float = 15): parsed = urllib.parse.urlsplit(base) if parsed.scheme not in ("https", "http") or not parsed.netloc or parsed.query or parsed.fragment: raise ClientError("base url") self.base = base.rstrip("/") self.token_file = token_file context = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context() self.open = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context)).open self.timeout = timeout def call(self, method: str, path: str, body: dict | None = None, headers: dict | None = None) -> tuple[int, dict]: head = {"Authorization": "Bearer " + pathlib.Path(self.token_file).read_text().strip(), "Accept": "application/json", **(headers or {})} data = None if body is not None: head["Content-Type"] = "application/json" data = json.dumps(body).encode() request = urllib.request.Request(self.base + path, method=method, headers=head, data=data) try: with self.open(request, timeout=self.timeout) as response: raw = response.read(1 << 20) return response.status, (json.loads(raw) if raw else {}) except urllib.error.HTTPError as error: error.close() raise ClientError(f"{method} {error.code}") from None class ApprovalEngine: """GET /v1/approvals/{id} and POST /v1/approvals/{id}/consume (approval-engine api.py).""" def __init__(self, base: str, token_file: str, ca_file: str | None = None): self.http = _Http(base, token_file, ca_file) @staticmethod def _path(approval_id: str) -> str: if not approval_id or "/" in approval_id or len(approval_id) > 128: raise ClientError("approval id") return "/v1/approvals/" + urllib.parse.quote(approval_id, safe="") def get(self, approval_id: str) -> dict: return self.http.call("GET", self._path(approval_id))[1] def consume(self, approval_id: str, request_digest: str, decision_id: str) -> dict: return self.http.call("POST", self._path(approval_id) + "/consume", {"request_digest": request_digest, "decision_id": decision_id})[1] class AuditCore: """POST /v1/events with Idempotency-Key = event id (audit-core docs/event-envelope.md).""" def __init__(self, base: str, token_file: str, ca_file: str | None = None): self.http = _Http(base, token_file, ca_file) def emit(self, event: dict) -> None: status, _ = self.http.call("POST", "/v1/events", event, {"Idempotency-Key": event["id"]}) if status not in (200, 201): raise ClientError(f"audit {status}")