"""Minimal HTTP layer for the per-tenant HUX service. Stdlib only. A ``Router`` maps method + path template to a handler; family modules register their routes with it. Every request resolves identity from trusted headers, checks the owning card's flag, and writes an audit outcome. Errors always leave as ``hux.error.v1``. """ from __future__ import annotations import json import math import os import re import threading import time from collections.abc import Callable, Mapping from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlsplit from hux import audit from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, TooLarge from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call from hux.identity import Identity, resolve from hux.store import TenantStore MAX_BODY_BYTES = 1024 * 1024 MAX_QUERY_FIELDS = 64 DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0 DEFAULT_READS_PER_MINUTE = 300 DEFAULT_WRITES_PER_MINUTE = 30 MAX_RATE_BUCKETS = 4096 Handler = Callable[["Request"], "Response"] def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float: """Read one numeric setting, using its safe default when malformed or outside bounds.""" try: value = float(environ.get(name, default)) except (TypeError, ValueError): return default return value if math.isfinite(value) and low <= value <= high else default class RateLimiter: """Small fixed-window limiter bounded by active subjects in the current minute.""" def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None: self.limits = {"read": reads, "write": writes} self.clock = clock self._windows: dict[tuple[str, str], tuple[float, int]] = {} self._lock = threading.Lock() def check(self, subject: str, method: str) -> int | None: """Consume one request and return Retry-After seconds when its bucket is full.""" now = self.clock() bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write" key = (subject, bucket) with self._lock: # Each accepted identity can leave at most two current entries; # expired identities disappear whenever any request arrives. self._windows = {item: value for item, value in self._windows.items() if value[0] > now} reset, used = self._windows.get(key, (now + 60.0, 0)) if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS: return 60 if used >= self.limits[bucket]: return max(1, math.ceil(reset - now)) self._windows[key] = (reset, used + 1) return None @dataclass class Request: """Everything a handler needs; no raw socket access.""" method: str path: str params: dict[str, str] query: dict[str, str] headers: Mapping[str, str] body: Any identity: Identity store: TenantStore flags: Flags flags_build: dict[str, str] = field(default_factory=dict) def if_match(self) -> int | None: """Parsed If-Match revision, or None when absent.""" raw = self.header("If-Match") if raw == "": return None if not raw.isdigit(): raise Invalid("If-Match must be a revision integer") return int(raw) def idempotency_key(self) -> str: """Client idempotency key, validated against the contract pattern.""" raw = self.header("Idempotency-Key") if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw): raise Invalid("malformed Idempotency-Key") return raw def header(self, name: str) -> str: """Case-insensitive header lookup.""" for key, value in self.headers.items(): if key.lower() == name.lower(): return value.strip() return "" def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None: """Write an audit outcome for this request.""" audit.record(self.store, self.identity, action, resource, outcome, reason) @dataclass class Response: """JSON (or SSE) response.""" status: int = 200 body: Any = None headers: dict[str, str] = field(default_factory=dict) stream: Callable[[], Any] | None = None @dataclass class Route: """One registered handler.""" method: str template: str card: str action: str handler: Handler max_body: int = MAX_BODY_BYTES pattern: re.Pattern = field(init=False) def __post_init__(self) -> None: regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template) self.pattern = re.compile(f"^{regex}$") class Router: """Route table plus the request pipeline.""" def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None: self.data_root = Path(data_root) self.environ = dict(os.environ if environ is None else environ) self.flags = Flags(self.environ) self.build = build_from_environ(self.environ) self.routes: list[Route] = [] reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000)) writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000)) self.request_timeout = _bounded_number( self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0 ) self.rate_limiter = RateLimiter(reads, writes) def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None: """Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap.""" self.routes.append(Route(method, template, card, action, handler, max_body)) def bind_capability_routes(self) -> None: """Make feature negotiation depend on the route templates this process really registered.""" routes: dict[str, set[str]] = {} for route in self.routes: routes.setdefault(route.card, set()).add(route.template) self.flags.bind_routes(routes) def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]: """Return (route, params, path_known).""" known = False for route in self.routes: found = route.pattern.match(path) if found: known = True if route.method == method: return route, found.groupdict(), True return None, {}, known def body_limit(self, method: str, raw_path: str) -> int: """Maximum declared body for a matching route, before a socket read allocates it.""" route, _, _ = self.match(method, urlsplit(raw_path).path) return route.max_body if route is not None else MAX_BODY_BYTES def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response: """Run the full pipeline and never raise.""" parts = urlsplit(raw_path) route: Route | None = None try: identity = resolve(headers, self.environ) store = TenantStore(self.data_root, identity) except HuxError as error: return Response(error.status, error.record()) try: retry_after = self.rate_limiter.check(identity.subject, method) if retry_after is not None: raise RateLimited(retry_after) route, params, known = self.match(method, parts.path) if route is None: error = Invalid("method not allowed") if known else NotFound("no such route") audit.record(store, identity, "http.route", parts.path, "not_found", error.message) return Response(405 if known else 404, error.record()) # SO-08: the worker allowlist is checked before the flag so a # worker cannot even learn which cards are on. if identity.trust == "worker" and not worker_may_call(method, route.template): raise Forbidden("route is not available to worker trust") if identity.trust == "evidence": allowed = { ("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"), ("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"), ("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"), } if (method, route.template) not in allowed: raise Forbidden("route is not available to evidence trust") self.flags.require(route.card) try: parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS) except ValueError as error: raise Invalid("query has too many fields") from error query = {key: values[-1] for key, values in parsed_query.items()} payload = self._decode(body, route.max_body) request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build) response = route.handler(request) except HuxError as error: outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny") action = route.action if route is not None else "http.route" audit.record(store, identity, action, parts.path, outcome, error.message) headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {} return Response(error.status, error.record(), headers) except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked error = HuxError("internal error") action = route.action if route is not None else "http.route" audit.record(store, identity, action, parts.path, "deny", error.message) return Response(error.status, error.record()) return response @staticmethod def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any: if not body: return None if len(body) > max_body: raise TooLarge(f"body exceeds {max_body} bytes") try: return json.loads(body) except json.JSONDecodeError as error: raise Invalid(f"body is not JSON: {error.msg}") from error def page(items: list[Any], next_cursor: Any = None) -> Response: """Standard list envelope.""" return Response(200, {"items": items, "next": next_cursor}) def make_handler(router: Router) -> type[BaseHTTPRequestHandler]: """Bind a Router to a BaseHTTPRequestHandler subclass.""" class HuxHandler(BaseHTTPRequestHandler): server_version = "hux-foundation/1.0" def log_message(self, fmt: str, *args: Any) -> None: return def _run(self) -> None: try: if self.headers.get("Transfer-Encoding"): raise Invalid("Transfer-Encoding is not supported") lengths = self.headers.get_all("Content-Length", []) if len(lengths) > 1: raise Invalid("duplicate Content-Length is not supported") raw_length = lengths[0] if lengths else "0" if not raw_length.isascii() or not raw_length.isdigit(): raise Invalid("Content-Length must be a non-negative integer") if len(raw_length) > 10: raise TooLarge("declared body length is too large") length = int(raw_length) limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path) if length > limit: raise TooLarge(f"body exceeds {limit} bytes") body = self.rfile.read(length) if length else b"" if len(body) != length: raise Invalid("request body is incomplete or timed out") if self.path == "/healthz": body = {"status": "ok", "contract_version": CONTRACT_VERSION} scheduler = getattr(router, "retention_scheduler", None) if scheduler is not None: body["retention"] = scheduler.health() self._send(Response(200, body)) return self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body)) except HuxError as error: self._send(Response(error.status, error.record())) except (OSError, TimeoutError): error = Invalid("request body is incomplete or timed out") self._send(Response(error.status, error.record())) def _send(self, response: Response) -> None: if response.stream is not None: self.send_response(response.status) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() for chunk in response.stream(): self.wfile.write(chunk) self.wfile.flush() return data = json.dumps(response.body, sort_keys=True).encode() self.send_response(response.status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") for key, value in response.headers.items(): self.send_header(key, value) self.end_headers() self.wfile.write(data) do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run return HuxHandler class BoundedHTTPServer(ThreadingHTTPServer): """Threading server that bounds header and body socket reads from accept onward.""" def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float, scheduler: Any = None) -> None: self.request_timeout = timeout self.scheduler = scheduler super().__init__(address, handler) def get_request(self) -> tuple[Any, Any]: """Accept one connection and apply its per-request socket deadline.""" request, address = super().get_request() request.settimeout(self.request_timeout) return request, address def server_close(self) -> None: """Stop background work before closing the listening socket.""" if self.scheduler is not None: self.scheduler.stop() super().server_close() def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer: """Create (but do not start) the server; callers call serve_forever().""" from hux.retention_scheduler import RetentionScheduler scheduler = RetentionScheduler(router) router.retention_scheduler = scheduler server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout, scheduler) server.daemon_threads = True scheduler.start() return server