agnes-the-ai-analyst/app/main.py
Petr Simecek 9b5214ea6f
feat(dev): LOCAL_DEV_MODE for one-command local dev + magic-link fixes (#32)
* feat(dev): add LOCAL_DEV_MODE for one-command local dev

When LOCAL_DEV_MODE=1, every protected route auto-authenticates as a seeded
admin user (default dev@localhost) — no login screen, no Google OAuth config,
no magic-link roundtrip. Startup logs a loud warning to make misuse obvious.

Also fixes two preexisting bugs in the magic-link flow that surfaced while
wiring up the dev fallback:

- /auth/email/verify only accepted POST, but the URL embedded in emails is
  a GET link — clicking from any mail client returned 405. Added a GET
  variant that consumes the token, sets the auth cookie, and redirects to
  /dashboard.
- Token expiry check compared an offset-aware datetime.now(timezone.utc)
  against an offset-naive value from DuckDB, raising TypeError on every
  valid link. Normalize the stored timestamp to UTC before subtracting.

Dev-only fallback (scoped strictly to LOCAL_DEV_MODE to keep test and
production behavior identical): send-link logs the magic link to stderr
and returns it as dev_link in the JSON response when no SMTP is configured.

Usage:
  ./scripts/run-local-dev.sh
  open http://localhost:8000  # lands on /dashboard as admin

* fix(dev): URL-encode magic-link email + avoid /login redirect loop

Two issues surfaced by Devin review on PR #32.

1. _build_magic_link interpolated email into the URL unescaped. For addresses
   with '+' (e.g. user+tag@gmail.com) Starlette's query parser decoded '+'
   as a space on the GET /verify side, so repo.get_by_email returned None
   and every click yielded 401 "Invalid link". quote(email, safe='') fixes
   both the email transport and the dev_link fallback.

2. /login in LOCAL_DEV_MODE unconditionally redirected to /dashboard. If
   dev-user seeding failed at startup (main.py wraps seed in try/except),
   /dashboard 401'd, the HTML redirect handler bounced to /login, and the
   loop repeated until the browser aborted. Now /login checks the dev user
   actually exists before short-circuiting; otherwise it falls through to
   the normal login form so the missing seed is visible.
2026-04-22 14:47:33 +02:00

205 lines
8.4 KiB
Python

"""FastAPI main application — unified server for web UI + API."""
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import quote
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.auth.router import router as auth_router
from app.api.health import router as health_router
from app.api.sync import router as sync_router
from app.api.data import router as data_router
from app.api.query import router as query_router
from app.api.users import router as users_router
from app.api.memory import router as memory_router
from app.api.upload import router as upload_router
from app.api.scripts import router as scripts_router
from app.api.settings import router as settings_router
from app.api.catalog import router as catalog_router
from app.api.telegram import router as telegram_router
from app.api.admin import router as admin_router
from app.api.permissions import router as permissions_router
from app.api.access_requests import router as access_requests_router
from app.api.jira_webhooks import router as jira_webhooks_router
from app.api.metrics import router as metrics_router
from app.api.metadata import router as metadata_router
from app.api.query_hybrid import router as query_hybrid_router
from app.api.cli_artifacts import router as cli_artifacts_router
from app.api.tokens import router as tokens_router, admin_router as tokens_admin_router
from app.web.router import router as web_router
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app):
yield
from src.db import close_system_db
close_system_db()
def create_app() -> FastAPI:
app = FastAPI(
title="AI Data Analyst",
description="Data distribution platform for AI analytical systems",
version="2.0.0",
lifespan=lifespan,
)
# Session middleware (required for OAuth state)
from app.secrets import get_session_secret
session_secret = get_session_secret()
app.add_middleware(SessionMiddleware, secret_key=session_secret)
# CORS for CLI and external clients
cors_origins = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:8000").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in cors_origins],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Load .env_overlay (persisted by /api/admin/configure)
_overlay = Path(os.environ.get("DATA_DIR", "./data")) / "state" / ".env_overlay"
if _overlay.exists():
for line in _overlay.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
# Load instance config on startup
try:
from app.instance_config import load_instance_config
load_instance_config()
logger.info("Instance config loaded")
except Exception as e:
logger.warning(f"Could not load instance config: {e}")
# Startup banner
from src.db import SCHEMA_VERSION
logger.info(
"Agnes %s | channel: %s | schema v%s",
os.environ.get("AGNES_VERSION", "dev"),
os.environ.get("RELEASE_CHANNEL", "dev"),
SCHEMA_VERSION,
)
# LOCAL_DEV_MODE: bypass authentication for local development. DO NOT enable in prod.
# When on, every protected route auto-logs in as a seeded admin user (default dev@localhost).
from app.auth.dependencies import is_local_dev_mode, get_local_dev_email
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("LOCAL_DEV_MODE is ON — authentication is bypassed.")
logger.warning("All requests auto-authenticate as: %s", get_local_dev_email())
logger.warning("NEVER enable this in a deployment reachable from the internet.")
logger.warning("=" * 60)
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set) OR for local dev.
# Optional: SEED_ADMIN_PASSWORD sets password_hash on first seed so the user
# can log in immediately without bootstrap. Only applied if the user has no
# password_hash yet — never overwrites an existing password.
seed_email = os.environ.get("SEED_ADMIN_EMAIL") or (get_local_dev_email() if is_local_dev_mode() else None)
if seed_email:
try:
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
repo = UserRepository(conn)
seed_password = os.environ.get("SEED_ADMIN_PASSWORD") or None
password_hash = None
if seed_password:
from argon2 import PasswordHasher
password_hash = PasswordHasher().hash(seed_password)
existing = repo.get_by_email(seed_email)
if not existing:
import uuid
repo.create(
id=str(uuid.uuid4()),
email=seed_email,
name="Admin",
role="admin",
password_hash=password_hash,
)
logger.info("Seeded admin user: %s (password=%s)", seed_email, "yes" if password_hash else "no")
elif password_hash and not existing.get("password_hash"):
repo.update(id=existing["id"], password_hash=password_hash, role="admin")
logger.info("Set password on existing seed admin: %s", seed_email)
conn.close()
except Exception as e:
logger.warning(f"Could not seed admin: {e}")
# Static files
static_dir = Path(__file__).parent / "web" / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Auth providers (conditional registration)
from app.auth.providers.google import router as google_auth_router, is_available as google_available
from app.auth.providers.password import router as password_auth_router
from app.auth.providers.email import router as email_auth_router, is_available as email_available
# API routers
app.include_router(auth_router)
app.include_router(google_auth_router)
app.include_router(password_auth_router)
app.include_router(email_auth_router) # Always register, check availability per-request
app.include_router(health_router)
app.include_router(sync_router)
app.include_router(data_router)
app.include_router(query_router)
app.include_router(users_router)
app.include_router(memory_router)
app.include_router(upload_router)
app.include_router(scripts_router)
app.include_router(settings_router)
app.include_router(catalog_router)
app.include_router(telegram_router)
app.include_router(admin_router)
app.include_router(permissions_router)
app.include_router(access_requests_router)
app.include_router(jira_webhooks_router)
app.include_router(metrics_router)
app.include_router(metadata_router)
app.include_router(query_hybrid_router)
app.include_router(cli_artifacts_router)
app.include_router(tokens_router)
app.include_router(tokens_admin_router)
# Web UI router (must be last — has catch-all routes)
app.include_router(web_router)
@app.exception_handler(StarletteHTTPException)
async def _html_auth_redirect_handler(request, exc: StarletteHTTPException):
"""Redirect unauthenticated HTML page loads (GET) to /login.
Only GET requests outside `/api/` and `/auth/` are redirected — that
targets browser navigations to HTML pages. POSTs, API prefixes, and
non-401 errors fall through to Starlette's default JSON response so
JSON clients (including `/auth/tokens` for PAT CRUD) keep their
existing contract.
"""
if (
exc.status_code == 401
and request.method == "GET"
and not request.url.path.startswith(("/api/", "/auth/"))
):
next_param = quote(request.url.path, safe="")
return RedirectResponse(url=f"/login?next={next_param}", status_code=302)
from fastapi.exception_handlers import http_exception_handler
return await http_exception_handler(request, exc)
return app
app = create_app()