"""HTTP surface for the lab: a JSON API and a minimal browser UI. Stdlib only. The point of this layer is to give the agentic browser driver (T07) something real to navigate, and to give the mechanical mutations somewhere to bite — a mutation that moves a control is inert until there is a control to move. The domain is not reimplemented here. Every request funnels into `LabApp.request`, so the UI and API cannot drift from the enforcement path that the deterministic driver and the observation channel already exercise. """ from __future__ import annotations import json import re from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any from urllib.parse import parse_qs, urlparse from .app import Denied, LabApp def _resource_path(app: LabApp, resource_id: str) -> str: return ( f"/r/{resource_id}" if app.api_path_style == "short" else f"/resources/{resource_id}" ) def render_resource_page(app: LabApp, user_id: str, resource_id: str) -> str: """The browser surface. Every mechanical UI mutation shows up here.""" html = _render(app, user_id, resource_id) if app.ui_test_ids == "dropped": # A rewrite that does not carry the old test ids forward. See the # "test-id axis" note in lab/mutations.py — this is the case that # decides whether semantic actions earn their keep. html = _TEST_ID_ATTR.sub("", html) return html _TEST_ID_ATTR = re.compile(r'\s*data-td="[^"]*"') def _render(app: LabApp, user_id: str, resource_id: str) -> str: verbose = app.ui_labels == "verbose" share_label = "Give access" if verbose else "Share" revoke_label = "Withdraw access" if verbose else "Revoke" tag = "a" if app.ui_button_element == "anchor" else "button" role = ' role="button"' if tag == "a" else "" href = ' href="#"' if tag == "a" else "" subject_field = ( '' '' ) permission_field = ( '' '' ) fields = ( permission_field + subject_field if app.ui_field_order == "reversed" else subject_field + permission_field ) share_form = ( f'
' f"{fields}" f'<{tag}{href}{role} id="share-submit" data-td="share-submit">' f"{share_label}
" ) if app.ui_share_control == "modal": share_form = ( f'<{tag}{href}{role} id="open-share" data-td="open-share">{share_label}…' f"" f'{share_form}' ) confirm = ( '

This cannot be undone.

' f'<{tag}{href}{role} data-td="revoke-confirm">Yes, {revoke_label.lower()}' if app.ui_confirm_revoke else "" ) revoke_form = ( f'
' f'' f'<{tag}{href}{role} data-td="revoke-submit">{revoke_label}' f"{confirm}
" ) grants = "".join( f'
  • {sid}: {perm}
  • ' for (rid, sid), perm in sorted(app.grants.items()) if rid == resource_id ) body = ( f'

    Resource {resource_id}

    ' f'{share_form}{revoke_form}' ) if app.ui_dom_style == "nested": body = ( '
    ' f"{body}
    " ) return ( "Lab" f'{body}' ) class LabHandler(BaseHTTPRequestHandler): app: LabApp def log_message(self, *args: Any) -> None: # keep the test output quiet pass # -- helpers --------------------------------------------------------- def _token(self) -> str: header = self.headers.get("Authorization", "") return header.removeprefix("Bearer ").strip() def _send(self, status: int, payload: Any, content_type: str = "application/json") -> None: body = ( json.dumps(payload).encode() if content_type == "application/json" else payload.encode() ) self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _dispatch(self, op: str, **args: Any) -> None: try: self._send(200, self.app.request(self._token(), op, **args)) except Denied as denied: self._send(denied.status, {"error": str(denied)}) def _resource_id(self, path: str) -> str | None: parts = [p for p in path.split("/") if p] if parts and parts[0] in ("resources", "r") and len(parts) >= 2: return parts[1] return None # -- routes ---------------------------------------------------------- def do_GET(self) -> None: # noqa: N802 — stdlib naming url = urlparse(self.path) resource_id = self._resource_id(url.path) if resource_id is None: self._send(404, {"error": "not found"}) return if url.path.endswith("/view"): try: user_id = self.app._whoami(self._token()) except Denied as denied: self._send(denied.status, {"error": str(denied)}) return self._send(200, render_resource_page(self.app, user_id, resource_id), "text/html") return self._dispatch("read_resource", resource_id=resource_id) def do_POST(self) -> None: # noqa: N802 url = urlparse(self.path) resource_id = self._resource_id(url.path) length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length).decode() if length else "" try: body = json.loads(raw) if raw.startswith("{") else { k: v[0] for k, v in parse_qs(raw).items() } except json.JSONDecodeError: self._send(400, {"error": "malformed body"}) return if resource_id is None: if url.path.rstrip("/") in ("/resources", "/r"): self._dispatch("create_resource", **body) return self._send(404, {"error": "not found"}) return if url.path.endswith("/grant"): self._dispatch("grant", resource_id=resource_id, **body) elif url.path.endswith("/revoke"): self._dispatch("revoke", resource_id=resource_id, **body) elif url.path.endswith("/accept"): self._dispatch("accept_share", resource_id=resource_id) else: self._send(404, {"error": "not found"}) def serve(app: LabApp, port: int = 0) -> ThreadingHTTPServer: """Start a server on `port` (0 picks a free one). Caller owns shutdown.""" handler = type("BoundLabHandler", (LabHandler,), {"app": app}) return ThreadingHTTPServer(("127.0.0.1", port), handler) if __name__ == "__main__": # pragma: no cover - manual use import sys from .mutations import build_lab lab, tokens = build_lab(*sys.argv[1:]) lab.request(tokens["alice"], "create_resource", resource_id="R", content="the secret") server = serve(lab, 8099) print(f"{lab.version} on http://127.0.0.1:8099 tokens={tokens}") server.serve_forever()