server.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago
| 1 | """Stdlib loopback HTTP server for ``overseer app`` (§Q0.9).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import mimetypes |
| 7 | import threading |
| 8 | from dataclasses import dataclass |
| 9 | from http import HTTPStatus |
| 10 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 11 | from pathlib import Path |
| 12 | from typing import Callable |
| 13 | from urllib.parse import parse_qs, urlparse |
| 14 | |
| 15 | from cli.context import CliContext |
| 16 | from tools.app.auth import constant_time_equal, generate_csrf_token, generate_session_credential |
| 17 | from tools.app.bind import is_loopback_peer |
| 18 | from tools.app.cors import allowed_origins, origin_allowed |
| 19 | from tools.app.engine import ( |
| 20 | handle_docs_handover, |
| 21 | handle_docs_roadmap, |
| 22 | handle_gates, |
| 23 | handle_governance_sync, |
| 24 | handle_health, |
| 25 | handle_honesty_status, |
| 26 | handle_ledger_append, |
| 27 | handle_ledger_show, |
| 28 | handle_ledger_verify, |
| 29 | handle_review_freeze, |
| 30 | handle_status, |
| 31 | ) |
| 32 | from tools.app.envelope import ApiEnvelope, auth_refusal |
| 33 | |
| 34 | STATIC_ROOT = Path(__file__).resolve().parent / "static" |
| 35 | MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) |
| 36 | |
| 37 | RouteHandler = Callable[[BaseHTTPRequestHandler, dict | None], ApiEnvelope] |
| 38 | |
| 39 | |
| 40 | @dataclass(frozen=True) |
| 41 | class AppServerConfig: |
| 42 | """Runtime configuration for the local app server.""" |
| 43 | |
| 44 | repo_root: Path |
| 45 | bind: str |
| 46 | port: int |
| 47 | session_credential: str |
| 48 | csrf_token: str |
| 49 | ctx: CliContext |
| 50 | |
| 51 | |
| 52 | class AppHTTPServer(ThreadingHTTPServer): |
| 53 | """Threading HTTP server carrying frozen app configuration.""" |
| 54 | |
| 55 | daemon_threads = True |
| 56 | allow_reuse_address = True |
| 57 | |
| 58 | def __init__(self, config: AppServerConfig) -> None: |
| 59 | self.app_config = config |
| 60 | super().__init__((config.bind, config.port), AppRequestHandler) |
| 61 | |
| 62 | |
| 63 | class AppRequestHandler(BaseHTTPRequestHandler): |
| 64 | """Serve static UI assets and the closed ``api/*`` surface.""" |
| 65 | |
| 66 | server_version = "OverseerApp/1.0" |
| 67 | |
| 68 | def log_message(self, format: str, *args) -> None: # noqa: A003 |
| 69 | if self.server.app_config.ctx.output.verbose: |
| 70 | super().log_message(format, *args) |
| 71 | |
| 72 | def do_GET(self) -> None: # noqa: N802 |
| 73 | self._dispatch() |
| 74 | |
| 75 | def do_POST(self) -> None: # noqa: N802 |
| 76 | self._dispatch() |
| 77 | |
| 78 | def do_OPTIONS(self) -> None: # noqa: N802 |
| 79 | if not self._peer_ok(): |
| 80 | self._send_auth_refusal(auth_refusal(http_status=403, error="peer")) |
| 81 | return |
| 82 | self.send_response(HTTPStatus.NO_CONTENT) |
| 83 | self._write_cors_headers() |
| 84 | self.end_headers() |
| 85 | |
| 86 | def _dispatch(self) -> None: |
| 87 | config = self.server.app_config |
| 88 | if not self._peer_ok(): |
| 89 | self._send_auth_refusal(auth_refusal(http_status=403, error="peer")) |
| 90 | return |
| 91 | |
| 92 | parsed = urlparse(self.path) |
| 93 | path = parsed.path |
| 94 | |
| 95 | if path.startswith("/api/"): |
| 96 | if not self._auth_ok(require_csrf=self.command in MUTATING_METHODS): |
| 97 | return |
| 98 | if not self._origin_ok(): |
| 99 | self._send_auth_refusal(auth_refusal(http_status=403, error="origin")) |
| 100 | return |
| 101 | body = self._read_json_body() if self.command in MUTATING_METHODS else None |
| 102 | if body is _READ_ERROR: |
| 103 | return |
| 104 | envelope = self._route_api(path, body, query=parse_qs(parsed.query)) |
| 105 | self._send_envelope(envelope) |
| 106 | return |
| 107 | |
| 108 | if self.command != "GET": |
| 109 | self.send_error(HTTPStatus.METHOD_NOT_ALLOWED) |
| 110 | return |
| 111 | |
| 112 | if path in {"/", "/index.html"}: |
| 113 | self._serve_static(STATIC_ROOT / "index.html") |
| 114 | return |
| 115 | if path.startswith("/assets/"): |
| 116 | rel = path.removeprefix("/assets/") |
| 117 | self._serve_static(STATIC_ROOT / "assets" / rel) |
| 118 | return |
| 119 | |
| 120 | self.send_error(HTTPStatus.NOT_FOUND) |
| 121 | |
| 122 | def _route_api(self, path: str, body: dict | None, *, query: dict[str, list[str]]) -> ApiEnvelope: |
| 123 | config = self.server.app_config |
| 124 | ctx = config.ctx |
| 125 | repo_arg = str(config.repo_root) |
| 126 | |
| 127 | routes: dict[tuple[str, str], RouteHandler] = { |
| 128 | ("GET", "/api/health"): lambda _h, _b: handle_health( |
| 129 | port=config.port, |
| 130 | bind=config.bind, |
| 131 | repo_root=config.repo_root, |
| 132 | ), |
| 133 | ("GET", "/api/status"): lambda _h, _b: handle_status(ctx, repo_arg=repo_arg), |
| 134 | ("GET", "/api/gates"): lambda _h, _b: handle_gates(ctx, repo_arg=repo_arg), |
| 135 | ("GET", "/api/docs/roadmap"): lambda _h, _b: handle_docs_roadmap(ctx, repo_arg=repo_arg), |
| 136 | ("GET", "/api/docs/handover"): lambda _h, _b: handle_docs_handover(ctx, repo_arg=repo_arg), |
| 137 | ("GET", "/api/ledger/show"): lambda _h, _b: handle_ledger_show( |
| 138 | ctx, |
| 139 | repo_arg=repo_arg, |
| 140 | last=_parse_last_query(query), |
| 141 | ), |
| 142 | ("POST", "/api/review/freeze"): lambda _h, b: handle_review_freeze(ctx, b or {}, repo_arg=repo_arg), |
| 143 | ("POST", "/api/governance-sync"): lambda _h, b: handle_governance_sync(ctx, b or {}, repo_arg=repo_arg), |
| 144 | ("POST", "/api/ledger/verify"): lambda _h, b: handle_ledger_verify(ctx, b or {}, repo_arg=repo_arg), |
| 145 | ("POST", "/api/ledger/append"): lambda _h, b: handle_ledger_append(ctx, b or {}, repo_arg=repo_arg), |
| 146 | ("POST", "/api/honesty-status"): lambda _h, b: handle_honesty_status(ctx, b or {}, repo_arg=repo_arg), |
| 147 | } |
| 148 | |
| 149 | handler = routes.get((self.command, path)) |
| 150 | if handler is None: |
| 151 | return auth_refusal(http_status=404, error="not_found") |
| 152 | return handler(self, body) |
| 153 | |
| 154 | def _peer_ok(self) -> bool: |
| 155 | host = self.client_address[0] |
| 156 | return is_loopback_peer(host) |
| 157 | |
| 158 | def _auth_ok(self, *, require_csrf: bool) -> bool: |
| 159 | auth_header = self.headers.get("Authorization", "") |
| 160 | if not auth_header.startswith("Bearer "): |
| 161 | self._send_auth_refusal(auth_refusal(http_status=401, error="auth")) |
| 162 | return False |
| 163 | token = auth_header.removeprefix("Bearer ").strip() |
| 164 | if not constant_time_equal(token, self.server.app_config.session_credential): |
| 165 | self._send_auth_refusal(auth_refusal(http_status=401, error="auth")) |
| 166 | return False |
| 167 | if require_csrf: |
| 168 | csrf = self.headers.get("X-Overseer-CSRF", "") |
| 169 | if not constant_time_equal(csrf, self.server.app_config.csrf_token): |
| 170 | self._send_auth_refusal(auth_refusal(http_status=403, error="csrf")) |
| 171 | return False |
| 172 | return True |
| 173 | |
| 174 | def _origin_ok(self) -> bool: |
| 175 | return origin_allowed(self.headers.get("Origin"), self.server.app_config.port) |
| 176 | |
| 177 | def _read_json_body(self) -> dict | None | object: |
| 178 | length = int(self.headers.get("Content-Length", "0") or "0") |
| 179 | raw = self.rfile.read(length) if length else b"" |
| 180 | if not raw: |
| 181 | return {} |
| 182 | try: |
| 183 | parsed = json.loads(raw.decode("utf-8")) |
| 184 | except json.JSONDecodeError: |
| 185 | self._send_envelope(auth_refusal(http_status=400, error="bad_json")) |
| 186 | return _READ_ERROR |
| 187 | if not isinstance(parsed, dict): |
| 188 | self._send_envelope(auth_refusal(http_status=400, error="bad_json")) |
| 189 | return _READ_ERROR |
| 190 | return parsed |
| 191 | |
| 192 | def _send_envelope(self, envelope: ApiEnvelope) -> None: |
| 193 | self.send_response(envelope.http_status) |
| 194 | self.send_header("Content-Type", "application/json; charset=utf-8") |
| 195 | body = envelope.to_json_bytes() |
| 196 | self.send_header("Content-Length", str(len(body))) |
| 197 | self._write_cors_headers() |
| 198 | self.end_headers() |
| 199 | self.wfile.write(body) |
| 200 | |
| 201 | def _send_auth_refusal(self, envelope: ApiEnvelope) -> None: |
| 202 | self.send_response(envelope.http_status) |
| 203 | self.send_header("Content-Type", "application/json; charset=utf-8") |
| 204 | body = envelope.to_json_bytes() |
| 205 | self.send_header("Content-Length", str(len(body))) |
| 206 | self._write_cors_headers() |
| 207 | self.end_headers() |
| 208 | self.wfile.write(body) |
| 209 | |
| 210 | def _write_cors_headers(self) -> None: |
| 211 | origin = self.headers.get("Origin") |
| 212 | port = self.server.app_config.port |
| 213 | if origin and origin in allowed_origins(port): |
| 214 | self.send_header("Access-Control-Allow-Origin", origin) |
| 215 | self.send_header("Vary", "Origin") |
| 216 | self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Overseer-CSRF") |
| 217 | self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") |
| 218 | |
| 219 | def _serve_static(self, path: Path) -> None: |
| 220 | try: |
| 221 | resolved = path.resolve() |
| 222 | if not str(resolved).startswith(str(STATIC_ROOT.resolve())): |
| 223 | self.send_error(HTTPStatus.FORBIDDEN) |
| 224 | return |
| 225 | except OSError: |
| 226 | self.send_error(HTTPStatus.NOT_FOUND) |
| 227 | return |
| 228 | |
| 229 | if not resolved.is_file(): |
| 230 | self.send_error(HTTPStatus.NOT_FOUND) |
| 231 | return |
| 232 | |
| 233 | content_type, _ = mimetypes.guess_type(str(resolved)) |
| 234 | content = resolved.read_bytes() |
| 235 | self.send_response(HTTPStatus.OK) |
| 236 | self.send_header("Content-Type", content_type or "application/octet-stream") |
| 237 | self.send_header("Content-Length", str(len(content))) |
| 238 | self._write_cors_headers() |
| 239 | self.end_headers() |
| 240 | self.wfile.write(content) |
| 241 | |
| 242 | |
| 243 | _READ_ERROR = object() |
| 244 | |
| 245 | |
| 246 | def _parse_last_query(query: dict[str, list[str]]) -> int | None: |
| 247 | values = query.get("last") |
| 248 | if not values: |
| 249 | return None |
| 250 | try: |
| 251 | parsed = int(values[0]) |
| 252 | except ValueError: |
| 253 | return None |
| 254 | return parsed if parsed > 0 else None |
| 255 | |
| 256 | |
| 257 | @dataclass |
| 258 | class AppServerHandle: |
| 259 | """Running server handle for tests and CLI.""" |
| 260 | |
| 261 | config: AppServerConfig |
| 262 | httpd: AppHTTPServer |
| 263 | thread: threading.Thread |
| 264 | |
| 265 | @property |
| 266 | def base_url(self) -> str: |
| 267 | host = "127.0.0.1" if self.config.bind in {"127.0.0.1", "localhost"} else "[::1]" |
| 268 | return f"http://{host}:{self.config.port}" |
| 269 | |
| 270 | def shutdown(self) -> None: |
| 271 | self.httpd.shutdown() |
| 272 | self.thread.join(timeout=5) |
| 273 | |
| 274 | |
| 275 | def start_app_server(config: AppServerConfig) -> AppServerHandle: |
| 276 | """Bind and start the app server in a background thread.""" |
| 277 | httpd = AppHTTPServer(config) |
| 278 | thread = threading.Thread(target=httpd.serve_forever, name="overseer-app", daemon=True) |
| 279 | thread.start() |
| 280 | return AppServerHandle(config=config, httpd=httpd, thread=thread) |
| 281 | |
| 282 | |
| 283 | def build_server_config( |
| 284 | *, |
| 285 | repo_root: Path, |
| 286 | bind: str, |
| 287 | port: int, |
| 288 | ctx: CliContext, |
| 289 | session_credential: str | None = None, |
| 290 | csrf_token: str | None = None, |
| 291 | ) -> AppServerConfig: |
| 292 | """Construct server configuration with optional fixed credentials for tests.""" |
| 293 | return AppServerConfig( |
| 294 | repo_root=repo_root.resolve(), |
| 295 | bind=bind, |
| 296 | port=port, |
| 297 | session_credential=session_credential or generate_session_credential(), |
| 298 | csrf_token=csrf_token or generate_csrf_token(), |
| 299 | ctx=ctx, |
| 300 | ) |
| 301 | |
| 302 | |
| 303 | def run_app_server( |
| 304 | *, |
| 305 | repo_root: Path, |
| 306 | bind: str, |
| 307 | port: int, |
| 308 | ctx: CliContext, |
| 309 | on_ready: Callable[[AppServerConfig, str], None] | None = None, |
| 310 | ) -> int: |
| 311 | """Start the server and block until shutdown. Returns process exit code.""" |
| 312 | session = generate_session_credential() |
| 313 | csrf = generate_csrf_token() |
| 314 | config = build_server_config( |
| 315 | repo_root=repo_root, |
| 316 | bind=bind, |
| 317 | port=port, |
| 318 | ctx=ctx, |
| 319 | session_credential=session, |
| 320 | csrf_token=csrf, |
| 321 | ) |
| 322 | if on_ready is not None: |
| 323 | on_ready(config, f"{config.bind}:{config.port}") |
| 324 | handle = start_app_server(config) |
| 325 | try: |
| 326 | handle.thread.join() |
| 327 | except KeyboardInterrupt: |
| 328 | handle.shutdown() |
| 329 | return 0 |
File History
1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
1 day ago