360 lines
16 KiB
Python

"""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