From 7c3c7d17d8012a3428095940a2ad94a0ce8a82a7 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 20 Sep 2026 19:23:33 +0200 Subject: [PATCH] Rewrite FastAPI app and pipeline worker entrypoint. Worker still refuses to import backend.main. Auth router is mounted unchanged; CORS stays outermost so 401s keep headers. --- periscope/src/backend/main.py | 178 +++++++++++++++++++++++ periscope/src/backend/pipeline_worker.py | 109 ++++++++++++++ tests/test_periscope_app_rewrite.py | 45 ++++++ 3 files changed, 332 insertions(+) create mode 100644 periscope/src/backend/main.py create mode 100644 periscope/src/backend/pipeline_worker.py create mode 100644 tests/test_periscope_app_rewrite.py diff --git a/periscope/src/backend/main.py b/periscope/src/backend/main.py new file mode 100644 index 0000000..b67dda7 --- /dev/null +++ b/periscope/src/backend/main.py @@ -0,0 +1,178 @@ +"""FastAPI process for Periscope: lifespan, auth gate, CORS, routers. + +Does not implement login. ``auth.router`` is mounted as-is. GCS storage is +imported only when ``settings.use_gcs`` is true. +""" +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from backend.config import settings +from backend.routers import ( + admin, + auth, + contact, + feedback, + impedance, + pipeline, + projects, + reports, + survey, +) +from backend.services.projects import ProjectNotFound +from backend.services.storage import LocalStorageBackend + +logger = logging.getLogger(__name__) + +LOCAL_DEV_USER = "local" + +_PUBLIC_PATHS = frozenset( + { + "/api/contact", + "/api/auth/mode", + "/api/auth/register", + "/api/auth/login", + } +) + + +def _storage(): + if settings.use_gcs: + from backend.services.storage_gcs import GCSStorageBackend + + return GCSStorageBackend(settings.gcs_bucket) + return LocalStorageBackend(settings.data_dir) + + +def _ensure_local_dirs() -> None: + base = settings.data_dir + for rel in ( + "users", + "auth/users", + "auth/by_email", + "library/extracted", + "library/patterns", + "library/models", + "library/passives", + "library/datasheets/refs", + "library/datasheets/blobs", + ): + (base / rel).mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + env = os.getenv("ENVIRONMENT", "").lower() + if env == "production" and not settings.use_auth: + raise RuntimeError( + "Production requires authentication: set AUTH_JWT_SECRET " + "(local Periscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY." + ) + if not settings.use_auth: + logger.warning( + "Authentication is DISABLED — all users have full access. " + "This is only safe for local development." + ) + elif settings.use_local_auth: + logger.info("Local Periscope authentication enabled (AUTH_JWT_SECRET)") + elif settings.use_clerk: + logger.info("Clerk authentication enabled") + if not settings.billing_enabled: + logger.warning( + "Billing is DISABLED — pipelines run free and the billing/credits " + "routes are not mounted." + ) + + app.state.storage = _storage() + if isinstance(app.state.storage, LocalStorageBackend): + _ensure_local_dirs() + yield + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if settings.use_auth: + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + return response + + +class AuthMiddleware(BaseHTTPMiddleware): + """Attach ``request.state.user_id``. JWT verification stays in leftover middleware.""" + + async def dispatch(self, request: Request, call_next): + if request.method == "OPTIONS": + return await call_next(request) + if request.url.path in _PUBLIC_PATHS: + request.state.user_id = LOCAL_DEV_USER + return await call_next(request) + + if not settings.use_auth: + request.state.user_id = LOCAL_DEV_USER + return await call_next(request) + + from backend.middleware.auth import verify_request_user + + user_id = await verify_request_user(request) + if user_id is None: + production = os.getenv("ENVIRONMENT", "").lower() == "production" + if production or settings.use_local_auth: + return JSONResponse( + status_code=401, + content={"detail": "Authentication required"}, + ) + user_id = LOCAL_DEV_USER + request.state.user_id = user_id + return await call_next(request) + + +app = FastAPI( + title="PeriscopeX", + description="Agentic schematic validation API", + lifespan=lifespan, +) + +# Starlette LIFO: last added is outermost. CORS must wrap Auth 401s. +app.add_middleware(AuthMiddleware) +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["content-type", "authorization"], + expose_headers=["X-Datasheet-Url", "X-Datasheet-Source"], +) + + +@app.exception_handler(ProjectNotFound) +async def _project_not_found_handler(request: Request, exc: ProjectNotFound): + return JSONResponse(status_code=404, content={"detail": str(exc)}) + + +app.include_router(projects.router, prefix="/api") +app.include_router(pipeline.router, prefix="/api") +app.include_router(reports.router, prefix="/api") +app.include_router(impedance.router, prefix="/api") +app.include_router(admin.router, prefix="/api") +app.include_router(auth.router, prefix="/api") +if settings.billing_enabled: + from backend.routers import billing, credits + + app.include_router(billing.router, prefix="/api") + app.include_router(credits.router, prefix="/api") +app.include_router(contact.router, prefix="/api") +app.include_router(feedback.router, prefix="/api") +app.include_router(survey.router, prefix="/api") diff --git a/periscope/src/backend/pipeline_worker.py b/periscope/src/backend/pipeline_worker.py new file mode 100644 index 0000000..462bc92 --- /dev/null +++ b/periscope/src/backend/pipeline_worker.py @@ -0,0 +1,109 @@ +"""Child process that runs one pipeline job. Must not import ``backend.main``.""" +from __future__ import annotations + +import asyncio +import logging +import os +import sys + +from backend.config import settings +from backend.services import event_bridge +from backend.services import pipeline as pipeline_svc +from backend.services.storage import LocalStorageBackend, StorageBackend + +_TRUE = frozenset({"1", "true", "yes", "on"}) + + +def _storage() -> StorageBackend: + if settings.use_gcs: + from backend.services.storage_gcs import GCSStorageBackend + + return GCSStorageBackend(settings.gcs_bucket) + return LocalStorageBackend(settings.data_dir) + + +def _need(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise SystemExit(f"missing required env var: {name}") + return value + + +def _flag(name: str, default: bool = False) -> bool: + raw = os.environ.get(name, "").strip().lower() + if not raw: + return default + return raw in _TRUE + + +async def _dispatch() -> None: + project_id = _need("PROJECT_ID") + user_id = _need("USER_ID") + resume = _flag("RESUME") + free = _flag("FREE") + mode = (os.environ.get("MODE", "run").strip().lower() or "run") + execution_name = os.environ.get("EXECUTION_NAME", "").strip() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [worker %(name)s] %(message)s", + ) + log = logging.getLogger("backend.pipeline_worker") + log.info( + "starting worker mode=%s project=%s user=%s resume=%s free=%s execution=%s", + mode, + project_id, + user_id, + resume, + free, + execution_name or "(none)", + ) + + storage = _storage() + pipeline_svc.set_broker(event_bridge.GCSEventBroker(storage, user_id)) + pipeline_svc.broker.clear_history(project_id) + + if mode == "run": + await pipeline_svc.run_pipeline( + storage, user_id, project_id, resume=resume, free=free + ) + return + if mode == "regen": + stages = [ + part.strip() + for part in os.environ.get("REGEN_STAGES", "").split(",") + if part.strip() + ] + if not stages: + raise SystemExit("REGEN_STAGES must list at least one stage in regen mode") + await pipeline_svc.run_regen_pipeline(storage, user_id, project_id, stages) + return + if mode == "placement": + from backend.services import placement_pipeline + + await placement_pipeline.run_placement_pipeline(storage, user_id, project_id) + return + if mode == "pcb": + from backend.services import pcb_pipeline + + await pcb_pipeline.run_pcb_pipeline(storage, user_id, project_id) + return + raise SystemExit( + f"unknown MODE={mode!r}; expected 'run', 'regen', 'placement', or 'pcb'" + ) + + +def main() -> None: + try: + asyncio.run(_dispatch()) + except SystemExit: + raise + except KeyboardInterrupt: + sys.exit(130) + except BaseException as exc: # pragma: no cover + logging.exception("worker crashed before run_pipeline cleanup: %s", exc) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_periscope_app_rewrite.py b/tests/test_periscope_app_rewrite.py new file mode 100644 index 0000000..2f64147 --- /dev/null +++ b/tests/test_periscope_app_rewrite.py @@ -0,0 +1,45 @@ +"""Native FastAPI app and worker entrypoint resolve from src.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import backend.main as main_mod +import backend.pipeline_worker as worker_mod +from backend.main import LOCAL_DEV_USER, app + + +def _src(mod, name: str) -> Path: + path = Path(mod.__file__).resolve() + assert path.name == name + assert "src" in path.parts + assert "Native Periscope overlay" not in path.read_text(encoding="utf-8")[:400] + return path + + +def test_main_and_worker_are_src(): + _src(main_mod, "main.py") + _src(worker_mod, "pipeline_worker.py") + + +def test_worker_does_not_import_fastapi_app(): + tree = ast.parse(Path(worker_mod.__file__).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert alias.name != "backend.main" + if isinstance(node, ast.ImportFrom) and node.module: + assert not node.module.startswith("backend.main") + if node.module == "backend": + for alias in node.names: + assert alias.name != "main" + + +def test_app_mounts_auth_and_public_user(): + paths = set(app.openapi()["paths"]) + assert any(p.startswith("/api/auth") for p in paths) + assert any(p.startswith("/api/projects") for p in paths) + assert LOCAL_DEV_USER == "local" + assert "/api/auth/login" in main_mod._PUBLIC_PATHS + assert "/api/contact" in main_mod._PUBLIC_PATHS