# Native Periscope overlay: leftover app module still imported from src. # PinScope original remains in dependency/. """PeriscopeX backend — FastAPI application.""" 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__) # Default user ID for unauthenticated local dev LOCAL_DEV_USER = "local" def _create_storage(): """Create the appropriate storage backend based on config.""" if settings.use_gcs: from backend.services.storage_gcs import GCSStorageBackend return GCSStorageBackend(settings.gcs_bucket) return LocalStorageBackend(settings.data_dir) @asynccontextmanager async def lifespan(app: FastAPI): # Guard: refuse to start in production without authentication 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 = _create_storage() # For local backend, ensure base directories exist if isinstance(app.state.storage, LocalStorageBackend): base = settings.data_dir (base / "users").mkdir(parents=True, exist_ok=True) (base / "auth" / "users").mkdir(parents=True, exist_ok=True) (base / "auth" / "by_email").mkdir(parents=True, exist_ok=True) (base / "library" / "extracted").mkdir(parents=True, exist_ok=True) (base / "library" / "patterns").mkdir(parents=True, exist_ok=True) (base / "library" / "models").mkdir(parents=True, exist_ok=True) (base / "library" / "passives").mkdir(parents=True, exist_ok=True) (base / "library" / "datasheets" / "refs").mkdir(parents=True, exist_ok=True) (base / "library" / "datasheets" / "blobs").mkdir(parents=True, exist_ok=True) yield # Pipelines run in a separate Cloud Run Job worker (or local # subprocess in dev), so the API process has nothing to clean up # on shutdown. class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add standard security headers to all responses.""" 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: # Only set HSTS when running behind TLS in production response.headers["Strict-Transport-Security"] = ( "max-age=31536000; includeSubDomains" ) return response class AuthMiddleware(BaseHTTPMiddleware): """Extract user_id from JWT (Clerk or local) or default to local dev user.""" async def dispatch(self, request: Request, call_next): # Let CORS preflight through — browsers send OPTIONS without credentials if request.method == "OPTIONS": return await call_next(request) # Public endpoints that don't require authentication if request.url.path in { "/api/contact", "/api/auth/mode", "/api/auth/register", "/api/auth/login", }: request.state.user_id = LOCAL_DEV_USER return await call_next(request) if settings.use_auth: from backend.middleware.auth import verify_request_user user_id = await verify_request_user(request) if user_id is None: is_production = os.getenv("ENVIRONMENT", "").lower() == "production" # Local auth (and production) require a valid token for API routes. if is_production or settings.use_local_auth: from fastapi.responses import JSONResponse return JSONResponse( status_code=401, content={"detail": "Authentication required"}, ) # Non-production Clerk: fall back so missing token doesn't block # local development when Clerk is configured but unused. user_id = LOCAL_DEV_USER request.state.user_id = user_id else: request.state.user_id = LOCAL_DEV_USER response = await call_next(request) return response app = FastAPI( title="PeriscopeX", description="Agentic schematic validation API", lifespan=lifespan, ) # Middleware order matters: Starlette applies in LIFO order (last added = # outermost). CORSMiddleware MUST be outermost so that CORS headers are # present on every response — including 401s from AuthMiddleware. 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): # A mutation raced a project deletion (or hit never-fully-created metadata). # Return a clean 404 — CORSMiddleware is outermost, so headers still land. 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: # Import guarded too: with billing disabled the core never loads the # billing/credits routers (or, transitively, the Stripe SDK). 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")