"""FastAPI entrypoint. Wires the §17 routers, the OAuth callbacks, the webhook receiver, and the background reconciler. Per §4.2, single process, colocated SQLite — no need for a separate worker. """ from __future__ import annotations import logging import secrets from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import RedirectResponse from starlette.middleware.sessions import SessionMiddleware from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks from .bot import Bot from .config import load_config from .gitea import Gitea logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") log = logging.getLogger("rfc_app") @asynccontextmanager async def lifespan(app: FastAPI): config = load_config() db.run_migrations(config) db.init(config) gitea = Gitea(config) bot = Bot(gitea) reconciler = cache.Reconciler(config, gitea) # §18 carryover: the multi-provider LLM abstraction. Provider # construction can fail (missing key, wrong env value) — if it does, # the rest of the app still serves; chat endpoints surface a clear # 503 instead of crashing the process. try: providers = providers_mod.load_from_config(config) except Exception: log.exception("provider construction failed; chat will be disabled") providers = {} app.state.config = config app.state.gitea = gitea app.state.bot = bot app.state.reconciler = reconciler app.state.providers = providers app.include_router(_oauth_router(config)) app.include_router(api_routes.make_router(config, gitea, bot, providers)) app.include_router(webhooks.make_router(config, gitea)) reconciler.start() log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) try: yield finally: await reconciler.stop() await gitea.close() def create_app() -> FastAPI: # The secret key is required at app construction (SessionMiddleware # is added before lifespan runs), so we read just that one value # eagerly via load_config(). Everything else waits for lifespan. config = load_config() app = FastAPI(lifespan=lifespan) app.add_middleware( SessionMiddleware, secret_key=config.secret_key, session_cookie="rfc_session", max_age=60 * 60 * 24 * 30, https_only=False, ) return app app = create_app() def _oauth_router(config) -> APIRouter: router = APIRouter() @router.get("/auth/login") async def login(request: Request): state = auth.new_state() request.session[auth.SESSION_STATE_KEY] = state return RedirectResponse(auth.authorization_url(config, state)) @router.get("/auth/callback") async def callback(request: Request, code: str = "", state: str = ""): if not code: raise HTTPException(400, "Missing code") stored_state = request.session.get(auth.SESSION_STATE_KEY) if not stored_state or not secrets.compare_digest(stored_state, state): raise HTTPException(400, "Invalid state") token_data = await auth.exchange_code(config, code) access_token = token_data.get("access_token") if not access_token: raise HTTPException(400, "Token exchange failed") profile = await auth.fetch_user_profile(config, access_token) user = auth.provision_user(config, profile) auth.store_session(request, user) return RedirectResponse("/") @router.get("/auth/logout") async def logout(request: Request): request.session.clear() return RedirectResponse("/") return router