ca8ba69acb
Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17). Any valid email can sign in via OTC; a fresh user lands in permission_state='pending' with a captured first/last/why profile, and an admin grant flips them to 'granted' before write endpoints accept them. Grandfathered users pass through the migration with the column default 'granted' so existing contributors are unaffected. The allowed_emails table stays in the schema as a fast-path bypass pending v0.9.0's admin user-management page (item #7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""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,
|
|
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")
|
|
|
|
|
|
class OtcRequestBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
|
|
|
|
class OtcVerifyBody(BaseModel):
|
|
email: str = Field(min_length=3, max_length=320)
|
|
code: str = Field(min_length=1, max_length=16)
|
|
|
|
|
|
@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):
|
|
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,
|
|
}
|
|
|
|
return router
|