read.markets/app/main.py
Giorgio Gilestro f1903e1e61 public: landing + pricing + legal pages, apex-ready, lawyer-reviewed
Adds the unauthenticated surface that's needed to invite outsiders:

  - Landing (/) — dual-purpose root: dashboard for logged-in users,
    landing for everyone else. New maybe_current_user soft-auth helper
    in app/auth.py supports it without disturbing the per-route
    require_token deps on /news, /log, /upload, /settings.
  - About, Pricing, Disclaimer, Terms, Privacy — own router
    (app/routers/public.py), no auth dep, shared public_base layout
    (brand link, thin nav, footer with legal links + ICO ref + date).
  - Editorial positioning: news aggregator with a macro brain; tagline
    "Understand markets. Don't gamble on them."; anti-trading-as-gambling
    stance carried through About and Landing.

Legal pass following an independent lawyer-style review:

  - Privacy: explicit UK-GDPR Art. 6 lawful-basis section; Art. 22
    automated-decision line; explicit consent for sessionStorage sync
    key (PECR); 30-day IP-log retention; Art. 21 objection right;
    Children clause; Art. 33/34 breach-notification clause;
    international-transfer mechanism (IDTA + UK Addendum). ICO
    registration ZC098928 surfaced at the top.
  - Pricing: paid-card AI-portfolio-analysis bullet rewritten to remove
    advice-shaped wording ("what would invalidate the posture" gone);
    added italic carve-out citing FSMA / FCA COBS.
  - Disclaimer: separate EU/EEA carve-out + MAR 596/2014 Art. 3(1)(34)
    commentator safe-harbour; "qualifies the Terms" line; hallucination
    wording fixed.
  - Terms: cl.4 explicit AI-training prohibition + harassment line;
    cl.5 CCR 2013 14-day cancellation; cl.7 softened AI copyright
    claim under CDPA s.9(3) ambiguity; cl.8 proportionate suspension +
    pro-rata refund for paid users; cl.10 CRA 2015 Pt 1 statutory-rights
    carve-out from the liability cap; cl.11 right to close account on
    material change; cl.12 non-exclusive jurisdiction + UK consumer
    local courts.

Code-side enforcement of the Privacy claim:

  - openrouter.py: outbound OpenRouter calls now carry
    X-OR-Allow-Training: false. DeepSeek doesn't expose a per-request
    flag; the Privacy page discloses this caveat verbatim.

Apex domain prep:

  - branding.APP_URL flipped to https://read.markets (was app.). DNS for
    the apex already resolves; pending operator NPM step is a cert that
    covers the bare apex + a 301 from app.read.markets. No hard-coded
    subdomain references remain in code (verified with grep).

Nav + chrome:

  - app dropdown gains Pricing / Terms / Privacy / Disclaimer links.
  - login.html gains a small legal-links footer for the
    highest-leverage moment to surface them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 00:08:02 +02:00

92 lines
3.4 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.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from app import branding
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.routers import public as public_router
from app.routers import sync as sync_router
from app.routers import universe as universe_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")
s = get_settings()
if not s.PORTFOLIO_SYNC_PEPPER and not s.DATABASE_URL.startswith("sqlite"):
# Outer wrap still works (it just degrades to a per-user derived
# key with no shared secret), but a DB leak would let an attacker
# brute-force the PIN offline. Loud warning, not a hard failure.
log.warning("cassandra.portfolio_sync.pepper_missing")
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=branding.BRAND_NAME,
description="Macro-strategy dashboard",
version="0.1.0",
lifespan=lifespan,
)
# Gzip responses ≥500 bytes when the client sends Accept-Encoding: gzip.
# The Phase G universe payload is repetitive JSON that gzips to ~25-30%
# of raw size; compression is mandatory for that endpoint to be cheap.
app.add_middleware(GZipMiddleware, minimum_size=500)
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(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(sync_router.router, tags=["portfolio-sync"])
# Public router (no auth dep) before pages_router so the marketing/legal
# paths can never collide with future authenticated routes.
app.include_router(public_router.router)
app.include_router(pages_router.router, tags=["pages"])