read.markets/app/main.py
Giorgio Gilestro 480fd311c5 phase A: user accounts + session-cookie auth
Replaces the static bearer-token gate with a real auth boundary. The
existing CASSANDRA_TOKEN path is retained as an admin / scripting escape
hatch — kept compatible by aliasing require_token to require_auth.

- New users table (migration 0007): email, argon2 password_hash, tier,
  email_verified (declared but not enforced until phase E), settings_json
  for the tone/analysis/anchor knobs we'll wire in phase D.
- app/services/auth_service.py: argon2-cffi password hashing with timing-
  attack-resistant authenticate() (always runs a hash verify even on
  unknown-email to deny a username-enumeration oracle).
- app/auth.py rewritten: require_auth returns a CurrentUser with either
  is_admin=True (bearer path) or a User object (session path). Failing
  requests get 303 → /login for HTML, 401 for API. Sessions signed with
  itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL.
- app/routers/auth.py: /login, /signup, /logout. Login form preserves the
  ?next=… param for redirect-after-login. Signup respects a new
  CASSANDRA_SIGNUP_ENABLED flag.
- Standalone /login + /signup templates (no app chrome). base.html grows
  a user chip + logout link in the header (reads request.state.current_user).

Phase A's main known limitations are documented in the plan: email
verification is declared but not enforced; session revocation is
best-effort (cookie-only, not DB-backed). Both land in phase E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:12:10 +01:00

71 lines
2.2 KiB
Python

"""FastAPI entrypoint. Runs Alembic migrations on startup, bootstraps the
feeds table from TOML, mounts the API + HTML routers.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from alembic import command
from alembic.config import Config as AlembicConfig
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import pages as pages_router
from app.services.feeds_bootstrap import bootstrap_feeds
log = get_logger("cassandra")
APP_DIR = Path(__file__).resolve().parent
PROJECT_DIR = APP_DIR.parent
def _run_migrations() -> None:
"""Synchronous Alembic upgrade. Called once at lifespan startup."""
cfg = AlembicConfig(str(PROJECT_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(PROJECT_DIR / "alembic"))
cfg.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
command.upgrade(cfg, "head")
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging()
log.info("cassandra.startup")
try:
# Alembic's env.py uses asyncio.run() internally; offload it to a
# worker thread so it doesn't collide with FastAPI's running loop.
await asyncio.to_thread(_run_migrations)
log.info("cassandra.migrations.applied")
except Exception as e:
log.error("cassandra.migrations.failed", error=str(e))
raise
async with get_session_factory()() as session:
inserted = await bootstrap_feeds(session)
log.info("cassandra.feeds.bootstrap", inserted=inserted)
yield
log.info("cassandra.shutdown")
app = FastAPI(
title="Cassandra",
description="Macro-strategy dashboard",
version="0.1.0",
lifespan=lifespan,
)
app.mount(
"/static",
StaticFiles(directory=str(APP_DIR / "static")),
name="static",
)
app.include_router(auth_router.router, tags=["auth"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(pages_router.router, tags=["pages"])