"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённое /api + /mcp."""
import asyncio
import hashlib
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app import mcp_server
from app.actor import VIA_MCP, agent_actor
from app.api import attachments, events, garden, projects, tags, task_events, tasks, xp
from app.api.bugtrail import router as bugtrail_router
from app.api.mcp_tokens import router as mcp_tokens_router
from app.api.push import router as push_router
from app.api.repo_readme import router as repo_readme_router
from app.api.settings import router as settings_router
from app.auth.routes import router as auth_router
from app.auth.webhooks import router as auth_webhooks_router
from app.config import get_settings
from app.db import get_session_factory
from app.models import McpToken, User
from app.services import scheduler
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Менеджер сессий MCP требует запуска: mounted ASGI-приложение
собственный lifespan не получает.
Здесь же живёт планировщик напоминаний (ТЗ 3.21, 0.89). Задача, а не поток:
итерация уходит в `asyncio.to_thread`, потому что внутри синхронные SQLAlchemy
и `requests`, а сам цикл должен оставаться отменяемым — иначе он переживёт
приложение (то же правило, что и у SSE: процесс не должен оставлять сирот).
"""
async with mcp_server.mcp.session_manager.run():
reminder_task = asyncio.create_task(scheduler.scheduler_loop())
try:
yield
finally:
reminder_task.cancel()
with suppress(asyncio.CancelledError):
await reminder_task
app = FastAPI(title="gntodo API", version="0.1.0", lifespan=_lifespan)
# CORS: в разработке — vite dev server; в проде фронт отдаётся тем же origin через nginx.
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:15134"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Сессия — подписанная cookie (HttpOnly); секрет из окружения.
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
class McpAuthMiddleware(BaseHTTPMiddleware):
"""Bearer-токен /mcp → user_id владельца (per-user токены, ТЗ 3.10).
Токен хранится в БД только как sha256-хэш (mcp_tokens); найденные user_id и
актор (ТЗ 3.20) кладутся в request.state — тулы достают их через Context.
Актор здесь всегда агентский: объявить действие владельца тул может только
своим аргументом is_user, а не заголовком запроса.
"""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
if request.url.path.startswith("/mcp"):
auth = request.headers.get("authorization", "")
token = auth.removeprefix("Bearer ").strip()
user_id: str | None = None
if token:
token_hash = hashlib.sha256(token.encode()).hexdigest()
with get_session_factory()() as db:
row = db.execute(
select(McpToken.id, McpToken.label, McpToken.user_id, User.name)
.select_from(McpToken)
.outerjoin(User, User.id == McpToken.user_id)
.where(McpToken.token_hash == token_hash)
).first()
if row is not None:
user_id = row.user_id
request.state.gntodo_actor = agent_actor(
row.label or "", row.id, row.name or "", VIA_MCP
)
if not user_id:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
request.state.gntodo_user_id = user_id
return await call_next(request)
app.add_middleware(McpAuthMiddleware)
app.include_router(auth_router)
app.include_router(auth_webhooks_router)
app.include_router(tasks.router)
app.include_router(task_events.router)
app.include_router(projects.router)
app.include_router(tags.router)
app.include_router(attachments.router)
app.include_router(settings_router)
app.include_router(repo_readme_router)
app.include_router(bugtrail_router)
app.include_router(mcp_tokens_router)
app.include_router(push_router)
app.include_router(xp.router)
app.include_router(garden.router)
app.include_router(events.router)
# MCP-сервер для ИИ-агентов (M5)
app.mount("/mcp", mcp_server.mcp.streamable_http_app())
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}