"""FastAPI entrypoint. Runs Alembic migrations on startup, bootstraps the feeds table from TOML, mounts the API + HTML routers. """ from __future__ import annotations import asyncio from contextlib import asynccontextmanager from pathlib import Path from alembic import command from alembic.config import Config as AlembicConfig from fastapi import FastAPI from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from app import branding from app.config import get_settings from app.db import get_session_factory from app.logging import configure_logging, get_logger from app.routers import api as api_router from app.routers import auth as auth_router from app.routers import pages as pages_router from app.routers import public as public_router from app.routers import sync as sync_router from app.routers import universe as universe_router from app.services.feeds_bootstrap import bootstrap_feeds log = get_logger("cassandra") APP_DIR = Path(__file__).resolve().parent PROJECT_DIR = APP_DIR.parent def _run_migrations() -> None: """Synchronous Alembic upgrade. Called once at lifespan startup.""" cfg = AlembicConfig(str(PROJECT_DIR / "alembic.ini")) cfg.set_main_option("script_location", str(PROJECT_DIR / "alembic")) cfg.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL) command.upgrade(cfg, "head") @asynccontextmanager async def lifespan(app: FastAPI): configure_logging() log.info("cassandra.startup") s = get_settings() if not s.PORTFOLIO_SYNC_PEPPER and not s.DATABASE_URL.startswith("sqlite"): # Outer wrap still works (it just degrades to a per-user derived # key with no shared secret), but a DB leak would let an attacker # brute-force the PIN offline. Loud warning, not a hard failure. log.warning("cassandra.portfolio_sync.pepper_missing") try: # Alembic's env.py uses asyncio.run() internally; offload it to a # worker thread so it doesn't collide with FastAPI's running loop. await asyncio.to_thread(_run_migrations) log.info("cassandra.migrations.applied") except Exception as e: log.error("cassandra.migrations.failed", error=str(e)) raise async with get_session_factory()() as session: inserted = await bootstrap_feeds(session) log.info("cassandra.feeds.bootstrap", inserted=inserted) yield log.info("cassandra.shutdown") app = FastAPI( title=branding.BRAND_NAME, description="Macro-strategy dashboard", version="0.1.0", lifespan=lifespan, ) # Gzip responses ≥500 bytes when the client sends Accept-Encoding: gzip. # The Phase G universe payload is repetitive JSON that gzips to ~25-30% # of raw size; compression is mandatory for that endpoint to be cheap. app.add_middleware(GZipMiddleware, minimum_size=500) app.mount( "/static", StaticFiles(directory=str(APP_DIR / "static")), name="static", ) app.include_router(auth_router.router, tags=["auth"]) app.include_router(api_router.router, prefix="/api", tags=["api"]) app.include_router(universe_router.router, prefix="/api", tags=["universe"]) app.include_router(sync_router.router, tags=["portfolio-sync"]) # Public router (no auth dep) before pages_router so the marketing/legal # paths can never collide with future authenticated routes. app.include_router(public_router.router) app.include_router(pages_router.router, tags=["pages"])