"""Contract building blocks shared by the email side and the stubs.""" from __future__ import annotations import ipaddress import json import threading import urllib.error import urllib.request import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Callable, Mapping, Optional from . import CONTRACT_VERSION, transitions from .signing import AuthError, sign, verify _T = transitions() TERMINAL = frozenset(_T["terminal"]) def allowed(old: Optional[str], new: str) -> bool: """CONTRACT.md section 4. `old` None means a new handoff.""" return new in _T["transitions"].get(old or "(new)", []) def message(type_: str, case: Optional[dict] = None, **fields) -> dict: msg = {"mas_modes_version": CONTRACT_VERSION, "type": type_, "id": uuid.uuid4().hex} if case is not None: msg["case"] = case msg.update({k: v for k, v in fields.items() if v is not None}) return msg Signer = tuple[str, bytes] # (own peer id, key shared with the receiver) def post(url: str, msg: dict, signer: Optional[Signer] = None, timeout: float = 10.0) -> tuple[Optional[int], object]: """POST a contract message, signed when `signer` is given (CONTRACT.md 7.2). Returns (None, error) when the peer is unreachable.""" body = json.dumps(msg).encode() headers = {"Content-Type": "application/json"} if signer is not None: headers.update(sign(body, *signer)) req = urllib.request.Request(url, data=body, method="POST", headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, json.loads(r.read().decode() or "null") except urllib.error.HTTPError as e: try: return e.code, json.loads(e.read().decode() or "null") except (UnicodeDecodeError, json.JSONDecodeError): return e.code, None except (OSError, urllib.error.URLError) as e: return None, str(e) def refuse(error: str, detail: str = "", status: int = 409, **extra) -> tuple[int, dict]: return status, {"ok": False, "error": error, "detail": detail, **extra} class Idempotent: """Remember responses by message id (CONTRACT.md section 2).""" def __init__(self): self.seen: dict[str, tuple[int, dict]] = {} self.lock = threading.Lock() def __call__(self, msg: dict, handler: Callable[[dict], tuple[int, dict]]) -> tuple[int, dict]: mid = msg.get("id") with self.lock: if mid in self.seen: return self.seen[mid] out = handler(msg) if mid and out[0] < 500: with self.lock: self.seen[mid] = out return out Route = Callable[[dict, Optional[str], str], tuple[int, dict]] def _route(routes, path): if path in routes: return routes[path], path, "" for key in sorted(routes, key=len, reverse=True): if key.endswith("/") and path.startswith(key) and len(path) > len(key): return routes[key], key, path[len(key):] return None, None, "" def _loopback(host: str) -> bool: try: return ipaddress.ip_address(host).is_loopback except ValueError: return False def json_server(routes: dict[str, Route], keys: Optional[Mapping[str, bytes]] = None, unsigned: frozenset = frozenset(), host="127.0.0.1", port=0) -> ThreadingHTTPServer: """JSON over HTTP: POST path -> handler(body, peer, rest) -> (status, body). With `keys`, every request is authenticated before its body is parsed (CONTRACT.md 7.2); `peer` is the verified peer id. Routes in `unsigned` also accept requests without signature headers, from loopback only, with peer None. A key ending in "/" matches by prefix and passes the rest of the path (a per-handoff token) as `rest`.""" class Handler(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) fn, key, rest = _route(routes, self.path) if fn is None: return self._send(404, {"ok": False, "error": "not-found"}) peer = None if keys is not None: signed = any(h.lower().startswith("mas-modes-") for h in self.headers.keys()) if signed or key not in unsigned or not _loopback(self.client_address[0]): try: peer = verify(self.headers, body, keys) except AuthError as e: return self._send(e.status, {"ok": False, "error": e.code}) try: data = json.loads(body.decode()) except (UnicodeDecodeError, json.JSONDecodeError): return self._send(400, {"ok": False, "error": "invalid-json"}) self._send(*fn(data, peer, rest)) def _send(self, status, obj): out = json.dumps(obj).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(out))) self.end_headers() self.wfile.write(out) def log_message(self, *a): pass return ThreadingHTTPServer((host, port), Handler) def serve(server: ThreadingHTTPServer) -> str: """Run in a daemon thread; returns the base URL.""" threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start() return f"http://{server.server_address[0]}:{server.server_address[1]}"