Standalone router for inbound Polar (merchant-of-record) deliveries.
No bearer-token dep — authenticity comes from the Standard Webhooks
HMAC instead. Wired up so it's safe to deploy dark: empty
POLAR_WEBHOOK_SECRET makes the endpoint return 503 (loud) rather than
accept unsigned events.
Behaviour
- Standard Webhooks signature verification: HMAC-SHA256 over
`{webhook-id}.{webhook-timestamp}.{body}`, base64 secret prefixed
whsec_, ±5min replay window, constant-time compare against any of
the space-separated v1 tokens.
- Idempotency via UNIQUE on polar_events.event_id — a replayed
webhook-id short-circuits to 200 "duplicate" without re-running.
- Event dispatch table covers the 10 events we subscribed to:
subscription.{created,active,updated,uncanceled} -> tier=paid +
persist polar_customer_id / polar_subscription_id.
subscription.revoked -> tier=free (customer id kept so a resub
matches the same User row).
canceled / past_due / order.* / refund.created -> audit only.
- Unknown event types are acked 200 + recorded; we don't want to 4xx
on something Polar adds in the future and trigger their retry loop.
Schema (migration 0018)
- users.polar_customer_id, users.polar_subscription_id (both nullable
String(64)); UNIQUE on polar_customer_id so two users can't claim
the same Polar identity.
- polar_events table: event_id (unique), event_type, received_at,
processed_at, error, raw payload (truncated to 16 KiB).
Tests
- 7 in tests/test_polar_webhook.py: bad signature -> 401, stale
timestamp -> 401, missing headers -> 400, subscription.active flips
tier to paid + stores IDs, subscription.revoked drops to free while
keeping customer link, replayed webhook-id is no-op, unknown event
is acked.
- Full suite: 212 passed, 5 skipped.
Operator next steps before saving the webhook in Polar
1. Pull this branch to prod and apply migration 0018.
2. Save the webhook in Polar pointing at
https://read.markets/api/polar/webhook — Polar will accept the
save even though our endpoint still 503s (no secret yet).
3. Copy the secret Polar reveals into the prod .env as
POLAR_WEBHOOK_SECRET=whsec_... and restart the app.
4. Trigger a test event from Polar's dashboard to confirm 200 OK.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
99 lines
3.8 KiB
Python
99 lines
3.8 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 email as email_router
|
|
from app.routers import pages as pages_router
|
|
from app.routers import polar_webhook as polar_webhook_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(email_router.router, tags=["email"])
|
|
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"])
|
|
# Polar webhook (no bearer-token auth — authenticity via HMAC). Path
|
|
# `/api/polar/webhook` is set on the route itself so the URL Polar
|
|
# stores remains stable even if api_router's prefix ever moves.
|
|
app.include_router(polar_webhook_router.router, tags=["polar-webhook"])
|
|
# 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"])
|