Newer
Older
gnexus-tasks / backend / app / main.py
"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённое /api + /mcp."""

import hashlib
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager

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.api import attachments, events, garden, projects, tags, tasks, xp
from app.api.mcp_tokens import router as mcp_tokens_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


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
    """Менеджер сессий MCP требует запуска: mounted ASGI-приложение
    собственный lifespan не получает."""
    async with mcp_server.mcp.session_manager.run():
        yield


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
    кладётся в request.state — тулы достают его через Context.
    """

    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:
                    user_id = db.scalar(
                        select(McpToken.user_id).where(McpToken.token_hash == token_hash)
                    )
            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(projects.router)
app.include_router(tags.router)
app.include_router(attachments.router)
app.include_router(settings_router)
app.include_router(mcp_tokens_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"}