"""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 pydantic import BaseModel, Field from starlette.middleware.sessions import SessionMiddleware from . import ( api as api_routes, auth, cache, db, digest, email_otc, hygiene, otc, passcode as passcode_mod, providers as providers_mod, turnstile, 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") class OtcRequestBody(BaseModel): email: str = Field(min_length=3, max_length=320) # v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the # frontend widget. Optional in the body so a deployment that has # not yet wired the Turnstile site key (or a dev environment with # the widget intentionally skipped) still routes through the same # endpoint; the backend turnstile.verify_token call decides whether # to admit the request based on `TURNSTILE_REQUIRED` + presence of # the secret. turnstile_token: str | None = Field(default=None, max_length=4096) class OtcVerifyBody(BaseModel): email: str = Field(min_length=3, max_length=320) code: str = Field(min_length=1, max_length=16) class PasscodeSetBody(BaseModel): passcode: str = Field(min_length=1, max_length=64) class PasscodeVerifyBody(BaseModel): email: str = Field(min_length=3, max_length=320) passcode: str = Field(min_length=1, max_length=64) @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) digest_sched = digest.DigestScheduler() hygiene_sched = hygiene.HygieneScheduler(config=config, bot=bot) # §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() digest_sched.start() hygiene_sched.start() log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo) try: yield finally: await hygiene_sched.stop() await digest_sched.stop() 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) if not auth.is_allowed_sign_in(profile): # Private-beta gate: clear any partial OAuth state and bounce to # the public /beta-pending page. The session is left empty so the # rejected viewer continues as anonymous read-only. request.session.pop(auth.SESSION_STATE_KEY, None) return RedirectResponse("/beta-pending") 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("/") # --------------------------------------------------------------- # v0.7.0: email + one-time-code sign-in (§6.2). # # Replaces the OAuth gesture as the primary human-auth path. The # /auth/callback handler above remains functional as a fallback; # the new UI no longer surfaces it. A future release retires the # OAuth path entirely once every active user has signed in at # least once via OTC. # --------------------------------------------------------------- @router.post("/auth/otc/request") async def otc_request(body: OtcRequestBody, request: Request): # v0.12.0 / roadmap item #10: gate the request on a successful # Turnstile siteverify before the bcrypt hash + SMTP send. The # check runs first so a failed challenge spends no rate budget # and produces no envelope. When the operator has not wired the # secret AND TURNSTILE_REQUIRED=false (the default), the gate # opens — see `backend/app/turnstile.py` for the full matrix. client_ip = request.client.host if request.client else None ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip) if not ts.ok: if ts.reason == "misconfigured": # TURNSTILE_REQUIRED=true but the secret is unset. This # is an operator/config problem, not a client problem; # surface as 500 so the operator notices in their logs # rather than blaming the user's browser. raise HTTPException(500, "auth misconfigured") # missing-token / failed / network → uniform 400 so the # response does not enumerate which leg of the challenge # broke. The reason is in the server logs. raise HTTPException(400, "verification failed") outcome = otc.request_code(body.email) if outcome.reason == "cooldown": # Loud failure per the rate-limit primitive — the abuse # surface should be visible to clients hammering /request. raise HTTPException(429, "Wait before requesting another code") if outcome.sent and outcome.code is not None: email_otc.send_otc_email(body.email.strip(), outcome.code) # 202 regardless of allowlist/invalid — don't leak which # emails are recognized. return {"ok": True} @router.post("/auth/otc/verify") async def otc_verify(body: OtcVerifyBody, request: Request): result = otc.verify_code(body.email, body.code) if not result.ok or result.user is None: raise HTTPException(400, "Invalid or expired code") auth.store_session(request, result.user) # v0.8.0: surface `needs_profile` so the Login.jsx surface can # decide whether to advance to the first/last/why capture step # or jump straight to "/". `needs_profile=true` iff the user # is `permission_state='pending'` AND the row has no profile # fields yet — a fresh OTC user. Grandfathered users # (`permission_state='granted'`) and pending users who already # captured their fields both read as false. row = db.conn().execute( "SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?", (result.user.user_id,), ).fetchone() first_name = (row["first_name"] if row else None) or "" last_name = (row["last_name"] if row else None) or "" beta_request_reason = (row["beta_request_reason"] if row else None) or "" needs_profile = ( result.user.permission_state == "pending" and not first_name and not last_name and not beta_request_reason ) return { "ok": True, "user": { "id": result.user.user_id, "display_name": result.user.display_name, "email": result.user.email, "role": result.user.role, "permission_state": result.user.permission_state, }, "needs_profile": needs_profile, } # --------------------------------------------------------------- # v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8). # # After a successful OTC sign-in, a contributor may set a passcode # and use email + passcode for subsequent sign-ins. OTC remains the # forgot-passcode fallback — a verify failure beyond 5 consecutive # attempts locks the passcode path for 15 minutes; the OTC path is # unaffected by the lockout. # --------------------------------------------------------------- @router.get("/auth/passcode/check") async def passcode_check(email: str = ""): """Does this email have a passcode set? Anonymous endpoint — the Login.jsx flow calls this after the user types their email to decide whether to render a passcode input or fall back to OTC. We surface only the boolean; lockout state, the hash, and the set-at stamp are not leaked here.""" status = passcode_mod.passcode_status(email) return {"has_passcode": status.has_passcode} @router.post("/auth/passcode/set") async def passcode_set(body: PasscodeSetBody, request: Request): """Set or replace the signed-in user's passcode. Requires an active session (OTC- or passcode-authenticated).""" user = auth.require_user(request) try: passcode_mod.set_passcode(user.user_id, body.passcode) except passcode_mod.PasscodeValidationError as e: raise HTTPException(422, str(e)) return {"ok": True} @router.delete("/auth/passcode") async def passcode_delete(request: Request): """Remove the signed-in user's passcode. The user is back to OTC-only on next sign-in.""" user = auth.require_user(request) passcode_mod.clear_passcode(user.user_id) return {"ok": True} @router.post("/auth/passcode/verify") async def passcode_verify(body: PasscodeVerifyBody, request: Request): """Sign in with email + passcode. Returns the standard session payload on success; HTTP 423 with `locked_until` when the account is in the lockout window; HTTP 400 for every other failure (the wrong-vs-unknown distinction is intentionally collapsed so a probing client cannot enumerate emails).""" result = passcode_mod.verify_passcode(body.email, body.passcode) if result.reason == "locked": raise HTTPException( 423, { "detail": "Too many failed attempts; sign in with a one-time code instead", "locked_until": result.locked_until, }, ) if not result.ok or result.user is None: raise HTTPException(400, "Invalid passcode") auth.store_session(request, result.user) return { "ok": True, "user": { "id": result.user.user_id, "display_name": result.user.display_name, "email": result.user.email, "role": result.user.role, }, } return router