diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index b02896d..fe278bc 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -48,6 +48,9 @@ jobs: node --check "$f" || exit 1 done + - name: Run frontend collision and manifest checks + run: npm run check:frontend + - name: Build the frontend bundle run: node scripts/minify.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa44bc..ebff20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **CI frontend gate.** The manifest-driven JavaScript syntax and top-level declaration collision check now runs in the + SonarQube quality workflow, alongside Python compilation, regressions, and the production bundle build. +- **Structured request correlation.** API and MCP requests now receive a bounded `X-Request-ID` and emit sanitized + method/path/status/duration logs without recording request bodies or secrets. - **Audiobook parser extraction.** Dialogue detection, PDF dehyphenation, and shared speaker-heuristic constants now live in `audiobook-parse.js`, loaded before the Audiobook casting workflow without changing its legacy API. - **Rehearser voice-picker extraction.** Voice preview state and matched voice-choice presentation now live in diff --git a/TODO.md b/TODO.md index bc1fe3e..0c145c7 100644 --- a/TODO.md +++ b/TODO.md @@ -89,6 +89,10 @@ architecture change: the larger Audiobook/Rehearser/style split. - [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace bridge, and section-scoped lazy loading are complete foundations; responsibility-level extraction and the Audiobook/Rehearser/style split remain. +- [x] **M — Add structured logs with request/job IDs.** API/MCP requests now receive sanitized correlation IDs and + duration logs; route-specific logs retain provider/job context without request bodies. +- [x] **L — Add linting and duplicate-function checks.** The manifest-driven frontend syntax/declaration gate now runs + locally and in the SonarQube quality workflow alongside the existing Python and bundle checks. ### P2 — after behavior and safety stabilize diff --git a/VERSION b/VERSION index 3823fa5..f3d13e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.27 +1.24.29 diff --git a/server.py b/server.py index 2896390..83250d9 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import os import time +import uuid from logging.handlers import RotatingFileHandler from pathlib import Path from contextlib import asynccontextmanager @@ -141,6 +142,54 @@ class MutationRateLimitMiddleware: app.add_middleware(MutationRateLimitMiddleware) +# ── Request/job correlation logging ────────────────────────────────────────── +# Keep request diagnostics useful without recording request bodies. Route-level +# logs add provider/job details where appropriate; this middleware supplies one +# stable correlation ID and timing envelope for every API/MCP request. +class RequestCorrelationMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + path = scope.get("path", "") + if not (path.startswith("/api/") or path == "/mcp"): + await self.app(scope, receive, send) + return + request_id = next((value.decode("latin-1") for key, value in scope.get("headers", []) + if key.lower() == b"x-request-id"), "") + if not request_id or len(request_id) > 80 or any(ord(ch) < 33 or ord(ch) > 126 for ch in request_id): + request_id = uuid.uuid4().hex + started = time.perf_counter() + status = 500 + sent = False + + async def send_wrapper(message): + nonlocal status, sent + if message["type"] == "http.response.start": + status = int(message.get("status", 500)) + headers = MutableHeaders(scope=message) + headers["X-Request-ID"] = request_id + if message["type"] == "http.response.body" and not message.get("more_body", False) and not sent: + sent = True + elapsed_ms = (time.perf_counter() - started) * 1000 + logger.info("request id=%s method=%s path=%s status=%s duration_ms=%.1f", + request_id, scope.get("method", ""), path, status, elapsed_ms) + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + except Exception: + if not sent: + logger.info("request id=%s method=%s path=%s status=500 duration_ms=%.1f", + request_id, scope.get("method", ""), path, + (time.perf_counter() - started) * 1000) + raise + +app.add_middleware(RequestCorrelationMiddleware) + # ── In-memory log buffer handler ────────────────────────────────────────────── _buf_handler = _BufferHandler() diff --git a/static/index.html b/static/index.html index 1f954fe..895ab2a 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/tests/test_security.py b/tests/test_security.py index d81bfb5..05c89cb 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -12,10 +12,29 @@ from fastapi import HTTPException from core.validation import _validate_character_record, _validate_http_url, _validated_upload_suffix from core.config import _load_settings, _save_settings -from server import RequestSizeLimitMiddleware +from server import RequestCorrelationMiddleware, RequestSizeLimitMiddleware class SecurityValidationTests(unittest.TestCase): + def test_api_request_correlation_adds_safe_id_header(self): + async def endpoint(scope, receive, send): + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + async def run(request_id): + messages = [] + scope = {"type": "http", "method": "GET", "path": "/api/diagnostics", + "headers": [(b"x-request-id", request_id.encode())]} + async def send(message): messages.append(message) + await RequestCorrelationMiddleware(endpoint)(scope, lambda: None, send) + return dict(messages[0]["headers"]) + + headers = asyncio.run(run("test-request-123")) + self.assertEqual(headers[b"x-request-id"], b"test-request-123") + + generated = asyncio.run(run("bad\nrequest")) + self.assertRegex(generated[b"x-request-id"].decode(), r"^[0-9a-f]{32}$") + def test_character_record_rejects_missing_id_and_bad_sheet(self): for record in ( {"name": "Missing id", "sheet": {}},