""" PolyWeather Web Core Context """ import os from typing import Dict, Any, Optional from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from loguru import logger from src.utils.config_loader import load_config from src.utils.config_validation import validate_runtime_env from src.data_collection.weather_sources import WeatherDataCollector from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES # noqa: F401 from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer from src.auth.supabase_entitlement import SUPABASE_ENTITLEMENT, extract_bearer_token from src.database.db_manager import DBManager from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError # noqa: F401 from src.data_collection.city_registry import CITY_REGISTRY app = FastAPI(title="PolyWeather Map", version="1.0") _cors_origins = os.getenv( "WEB_CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,https://polyweather-pro.vercel.app", ) app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in _cors_origins.split(",") if o.strip()], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) _config = load_config() _config_validation = validate_runtime_env("web") for _warning in _config_validation.warnings: logger.warning(f"[config:web] {_warning}") if _config_validation.errors: raise RuntimeError(" | ".join(_config_validation.errors)) _weather = WeatherDataCollector(_config) _market_layer = PolymarketReadOnlyLayer() _account_db = DBManager() CITIES: Dict[str, Dict[str, Any]] = { cid: { "lat": info["lat"], "lon": info["lon"], "f": info["use_fahrenheit"], "tz": info["tz_offset"], "settlement_source": str(info.get("settlement_source") or "metar").strip().lower() or "metar", } for cid, info in CITY_REGISTRY.items() } SETTLEMENT_SOURCE_LABELS: Dict[str, str] = { "metar": "METAR", "hko": "HKO", "cwa": "CWA", "mgm": "MGM", } _cache: Dict[str, Dict] = {} CACHE_TTL = 300 CACHE_TTL_ANKARA = 60 def _env_bool(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} _ENTITLEMENT_GUARD_ENABLED = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False) _ENTITLEMENT_HEADER = "x-polyweather-entitlement" _ENTITLEMENT_TOKEN = (os.getenv("POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN") or "").strip() _FORWARDED_SUPABASE_USER_ID_HEADER = "x-polyweather-auth-user-id" _FORWARDED_SUPABASE_EMAIL_HEADER = "x-polyweather-auth-email" _SUPABASE_AUTH_REQUIRED = _env_bool( "POLYWEATHER_AUTH_REQUIRED", SUPABASE_ENTITLEMENT.enabled, ) def _legacy_service_token_valid(request: Request) -> bool: token = request.headers.get(_ENTITLEMENT_HEADER) if not token: token = extract_bearer_token(request.headers.get("authorization")) return bool(_ENTITLEMENT_TOKEN and token == _ENTITLEMENT_TOKEN) def _bind_optional_supabase_identity(request: Request) -> None: if not SUPABASE_ENTITLEMENT.configured: return access_token = extract_bearer_token(request.headers.get("authorization")) if not access_token: return identity = SUPABASE_ENTITLEMENT.get_identity(access_token) if not identity: return request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email request.state.auth_points = identity.points def _resolve_auth_points(request: Request) -> int: raw_points = getattr(request.state, "auth_points", 0) try: points = max(0, int(raw_points or 0)) except Exception: points = 0 if points > 0: return points user_id = str(getattr(request.state, "auth_user_id", "") or "").strip() if not user_id: return points try: db_points = _account_db.get_points_by_supabase_user_id(user_id) if db_points > points: request.state.auth_points = db_points return db_points except Exception as exc: logger.warning(f"auth points fallback failed user_id={user_id}: {exc}") return points def _resolve_weekly_profile(request: Request) -> Dict[str, Any]: user_id = str(getattr(request.state, "auth_user_id", "") or "").strip() if not user_id: return {"weekly_points": 0, "weekly_rank": None} try: profile = _account_db.get_weekly_profile_by_supabase_user_id(user_id) return { "weekly_points": int(profile.get("weekly_points") or 0), "weekly_rank": profile.get("weekly_rank"), } except Exception as exc: logger.warning(f"auth weekly profile fallback failed user_id={user_id}: {exc}") return {"weekly_points": 0, "weekly_rank": None} def _assert_entitlement(request: Request) -> None: if SUPABASE_ENTITLEMENT.enabled: if _legacy_service_token_valid(request): return if not _SUPABASE_AUTH_REQUIRED: _bind_optional_supabase_identity(request) return if not SUPABASE_ENTITLEMENT.configured: raise HTTPException( status_code=503, detail="Supabase auth is enabled but SUPABASE_URL / SUPABASE_ANON_KEY is not configured", ) access_token = extract_bearer_token(request.headers.get("authorization")) if not access_token: raise HTTPException(status_code=401, detail="Unauthorized") identity = SUPABASE_ENTITLEMENT.get_identity(access_token) if not identity: raise HTTPException(status_code=401, detail="Unauthorized") if not SUPABASE_ENTITLEMENT.has_active_subscription(identity.user_id): raise HTTPException(status_code=403, detail="Subscription required") request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email return if not _ENTITLEMENT_GUARD_ENABLED: return if not _ENTITLEMENT_TOKEN: raise HTTPException( status_code=503, detail="Entitlement guard is enabled but backend token is not configured", ) if not _legacy_service_token_valid(request): raise HTTPException(status_code=401, detail="Unauthorized") def _require_supabase_identity(request: Request) -> Dict[str, str]: if not SUPABASE_ENTITLEMENT.enabled: raise HTTPException(status_code=503, detail="payment requires POLYWEATHER_AUTH_ENABLED=true") if not SUPABASE_ENTITLEMENT.configured: raise HTTPException(status_code=503, detail="payment requires SUPABASE_URL and SUPABASE_ANON_KEY") state_user_id = str(getattr(request.state, "auth_user_id", "") or "").strip() if state_user_id: state_email = str(getattr(request.state, "auth_email", "") or "").strip() return {"user_id": state_user_id, "email": state_email} token = extract_bearer_token(request.headers.get("authorization")) if token: identity = SUPABASE_ENTITLEMENT.get_identity(token) if identity: return {"user_id": identity.user_id, "email": identity.email} legacy_ok = _legacy_service_token_valid(request) if legacy_ok: forwarded_user_id = str(request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or "").strip() if forwarded_user_id: forwarded_email = str(request.headers.get(_FORWARDED_SUPABASE_EMAIL_HEADER) or "").strip() return {"user_id": forwarded_user_id, "email": forwarded_email} logger.warning( "payment auth identity missing state_user={} auth_bearer={} legacy_ok={} forwarded_user={}" .format( bool(state_user_id), bool(token), bool(legacy_ok), bool(str(request.headers.get(_FORWARDED_SUPABASE_USER_ID_HEADER) or "").strip()), ) ) raise HTTPException(status_code=401, detail="Unauthorized") class WalletChallengeRequest(BaseModel): address: str = Field(..., min_length=8) class WalletVerifyRequest(BaseModel): address: str = Field(..., min_length=8) nonce: str = Field(..., min_length=6) signature: str = Field(..., min_length=20) class WalletUnbindRequest(BaseModel): address: str = Field(..., min_length=8) class CreatePaymentIntentRequest(BaseModel): plan_code: str = Field(default="pro_monthly", min_length=2) payment_mode: str = Field(default="strict") allowed_wallet: Optional[str] = None token_address: Optional[str] = None use_points: bool = False points_to_consume: Optional[int] = None metadata: Dict[str, Any] = Field(default_factory=dict) class SubmitPaymentTxRequest(BaseModel): tx_hash: str = Field(..., min_length=10) from_address: str = Field(..., min_length=8) class ConfirmPaymentTxRequest(BaseModel): tx_hash: Optional[str] = None def _sf(v) -> Optional[float]: if v is None: return None try: return float(v) except Exception: return None def _is_excluded_model_name(model_name: str) -> bool: normalized = str(model_name or "").strip().lower().replace(" ", "").replace("_", "").replace("-", "") return "meteoblue" in normalized