server.py python
329 lines 11.5 KB
Raw
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