""" cases_investigators_api.py — Public case files + investigator profiles Endpoints: POST /api/v1/cases — create/save a case snapshot GET /api/v1/cases/:id — fetch a case by id (public) GET /api/v1/cases — list cases by creator (auth) DELETE /api/v1/cases/:id — delete a case (creator only) GET /api/v1/investigators/:username — public investigator profile GET /api/v1/portfolio/features — returns tier-gated portfolio limits for current user POST /api/v1/subscription/addon — add an add-on to an existing subscription Designed to work with localStorage fallback on the frontend (cases are still public-shareable URLs even before this backend is wired). """ from __future__ import annotations import hashlib import json import logging import os import secrets from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field logger = logging.getLogger(__name__) router = APIRouter(tags=["cases-investigators"]) # ── Storage ─────────────────────────────────────────────────── # Cases are stored in Redis if available, else in-memory dict. # In production this should be Postgres/ClickHouse, but for the # public-share URL contract, this gives full functionality end-to-end. _cases_db: dict[str, dict] = {} _investigators_db: dict[str, dict] = {} try: import redis as _redis # type: ignore _r = _redis.Redis( host=os.getenv("REDIS_HOST", "localhost"), port=int(os.getenv("REDIS_PORT", "6379")), db=0, decode_responses=True, socket_timeout=2, ) _r.ping() REDIS_OK = True except Exception as e: REDIS_OK = False logger.warning(f"cases: redis unavailable, using in-memory store ({e})") def _store_case(case_id: str, data: dict) -> None: if REDIS_OK: _r.setex(f"rmi:case:{case_id}", 60 * 60 * 24 * 365, json.dumps(data)) _cases_db[case_id] = data def _load_case(case_id: str) -> dict | None: if REDIS_OK: raw = _r.get(f"rmi:case:{case_id}") if raw: return json.loads(raw) return _cases_db.get(case_id) def _delete_case(case_id: str) -> bool: deleted = False if REDIS_OK: deleted = bool(_r.delete(f"rmi:case:{case_id}")) if case_id in _cases_db: del _cases_db[case_id] deleted = True return deleted def _store_investigator(handle: str, data: dict) -> None: if REDIS_OK: _r.setex(f"rmi:investigator:{handle}", 60 * 60 * 24, json.dumps(data)) _investigators_db[handle] = data def _load_investigator(handle: str) -> dict | None: if REDIS_OK: raw = _r.get(f"rmi:investigator:{handle}") if raw: return json.loads(raw) return _investigators_db.get(handle) # ── Models ──────────────────────────────────────────────────── class CaseWallet(BaseModel): address: str label: str | None = None pct: float | None = None class CaseCreateRequest(BaseModel): chain: str token_address: str title: str risk_score: float = Field(ge=0, le=100) risk_level: str = "medium" description: str | None = None key_finding: str | None = None findings: list[str] = [] evidence_wallets: list[CaseWallet] = [] tx_hashes: list[str] = [] bubble_svg: str | None = None class AddonRequest(BaseModel): addon: str = Field(..., pattern="^(PORTFOLIO_PRO)$") period: str = Field(..., pattern="^(monthly|six_month|yearly)$") payment_method: str = Field("x402", pattern="^(stripe|crypto|x402)$") crypto_chain: str | None = None # ── Cases ───────────────────────────────────────────────────── @router.post("/cases") async def create_case(req: CaseCreateRequest, request: Request): """Create a public case file. Returns the case id + public URL.""" # Resolve creator if authed creator = "anonymous" try: from app.auth import get_current_user user = await get_current_user(request) if user: creator = ( user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id", "anonymous") ) except Exception: pass # Derive deterministic id from payload payload = { "chain": req.chain, "address": req.token_address, "risk_score": req.risk_score, "findings": req.findings, "wallets": [w.model_dump() for w in req.evidence_wallets], "txs": req.tx_hashes, "title": req.title, } payload_sorted = json.dumps(payload, sort_keys=True) full_hash = hashlib.sha256(payload_sorted.encode()).hexdigest() case_id = f"rmi-{full_hash[:12]}" full = { "id": case_id, "created_at": datetime.utcnow().isoformat() + "Z", "creator": creator, "chain": req.chain, "token_address": req.token_address, "title": req.title, "description": req.description, "risk_score": req.risk_score, "risk_level": req.risk_level, "key_finding": req.key_finding, "findings": req.findings, "evidence_wallets": [w.model_dump() for w in req.evidence_wallets], "tx_hashes": req.tx_hashes, "bubble_svg": req.bubble_svg, "snapshot_hash": full_hash, "url": f"https://rugmunch.io/case/{case_id}", } _store_case(case_id, full) return { "success": True, "case": full, "public_url": full["url"], } @router.get("/cases/{case_id}") async def get_case(case_id: str): """Public, no auth required. Returns 404 if not found.""" data = _load_case(case_id) if not data: raise HTTPException(status_code=404, detail=f"Case {case_id} not found") return data @router.get("/cases") async def list_my_cases(request: Request, limit: int = 50): """List cases created by the current user. Auth required.""" try: from app.auth import get_current_user user = await get_current_user(request) except Exception: user = None if not user: raise HTTPException(status_code=401, detail="Authentication required") creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id") matches = [c for c in _cases_db.values() if c.get("creator") == creator] matches.sort(key=lambda c: c.get("created_at", ""), reverse=True) return {"cases": matches[:limit], "total": len(matches)} @router.delete("/cases/{case_id}") async def delete_case(case_id: str, request: Request): """Delete a case. Only the creator (or anon if created anonymously) can delete.""" data = _load_case(case_id) if not data: raise HTTPException(status_code=404, detail="Case not found") try: from app.auth import get_current_user user = await get_current_user(request) except Exception: user = None creator = None if user: creator = user.get("user_metadata", {}).get("handle") or user.get("email", "").split("@")[0] or user.get("id") if data.get("creator") != "anonymous" and data.get("creator") != creator: raise HTTPException(status_code=403, detail="Not your case") _delete_case(case_id) return {"success": True} # ── Investigator profiles ───────────────────────────────────── @router.get("/investigators/{username}") async def get_investigator(username: str): """Public investigator profile. Returns synthesized profile if no record.""" handle = username.lower() data = _load_investigator(handle) if data: return data # Synthesize a default profile so the /u/:username page always renders return { "username": handle, "display_name": handle.replace("_", " ").replace("-", " ").title() if handle != "anonymous" else "Anonymous", "bio": f"RMI investigator @{handle}", "joined_at": (datetime.utcnow() - timedelta(days=90)).isoformat() + "Z", "reputation": 0, "cases_filed": 0, "scans_run": 0, "accuracy": 0, "badges": [], } @router.post("/investigators/{username}") async def upsert_investigator(username: str, request: Request): """Update or create investigator profile. Auth required.""" try: from app.auth import get_current_user user = await get_current_user(request) except Exception: user = None if not user: raise HTTPException(status_code=401, detail="Authentication required") body = await request.json() handle = username.lower() data = { "username": handle, "display_name": body.get("display_name", handle), "bio": body.get("bio", ""), "avatar_url": body.get("avatar_url"), "twitter": body.get("twitter"), "github": body.get("github"), "website": body.get("website"), "telegram": body.get("telegram"), "reputation": body.get("reputation", 0), "cases_filed": body.get("cases_filed", 0), "scans_run": body.get("scans_run", 0), "accuracy": body.get("accuracy", 0), "badges": body.get("badges", []), "updated_at": datetime.utcnow().isoformat() + "Z", } _store_investigator(handle, data) return {"success": True, "investigator": data} # ── Portfolio features (tier-gated limits) ─────────────────── _FREE_LIMITS = {"wallets": 3, "history_days": 7, "csv_export": False, "alerts": False} _PORTFOLIO_PRO_LIMITS = {"wallets": 50, "history_days": 30, "csv_export": True, "alerts": True} @router.get("/portfolio/features") async def portfolio_features(request: Request): """Return tier-gated Portfolio features for the current user. Resolves the user's effective tier by checking: 1. Supabase auth → user metadata.has_portfolio_pro 2. Their active subscription (if any) 3. Falls back to FREE limits Frontend uses this to gate wallets, history window, and exports. """ is_pro = False has_subscription = False base_tier = "FREE" try: from app.auth import get_current_user user = await get_current_user(request) except Exception: user = None if user: # Check user metadata flag if user.get("user_metadata", {}).get("has_portfolio_pro"): is_pro = True # Check subscription entitlements (Redis or DB) # Convention: rmi:sub:{user_id} → JSON with {tier, addons: [PORTFOLIO_PRO], ...} try: if REDIS_OK: sub_raw = _r.get(f"rmi:sub:{user.get('id')}") if sub_raw: sub = json.loads(sub_raw) base_tier = sub.get("tier", "FREE") has_subscription = True if "PORTFOLIO_PRO" in sub.get("addons", []): is_pro = True except Exception: pass limits = _PORTFOLIO_PRO_LIMITS if is_pro else _FREE_LIMITS return { "tier": "PORTFOLIO_PRO" if is_pro else base_tier, "is_portfolio_pro": is_pro, "has_subscription": has_subscription, "limits": limits, "price_usd": 3.00, "price_atoms": "3000000", "x402_chain_id": 8453, "x402_pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), "upgrade_url": "/pricing?tier=PORTFOLIO_PRO", } # ── Add-on subscription ────────────────────────────────────── @router.post("/subscription/addon") async def add_addon(req: AddonRequest, request: Request): """Add an add-on (e.g. PORTFOLIO_PRO) to an existing subscription. Returns an x402 challenge for micropayment, or a stripe checkout URL for fiat, or a crypto payment address. """ from app.auth import get_current_user try: user = await get_current_user(request) except Exception: user = None if not user: raise HTTPException(status_code=401, detail="Authentication required") addon = req.addon.upper() if addon == "PORTFOLIO_PRO": price_usd = 3.00 x402_price_atoms = "3000000" else: raise HTTPException(status_code=400, detail=f"Unknown addon {addon}") addon_id = secrets.token_hex(8) now = datetime.utcnow() if req.payment_method == "x402": # Return EIP-3009 challenge return { "success": True, "addon": addon, "addon_id": addon_id, "user_id": user["id"], "x402": { "version": 2, "scheme": "exact", "network": "eip155:8453", "asset": "USDC", "amount_atoms": x402_price_atoms, "amount_usd": price_usd, "pay_to": os.getenv("X402_PAY_TO", "0x1E3AC01d0fdb976179790BDD02823196A92705C9"), "expires_at": (now + timedelta(minutes=10)).isoformat() + "Z", }, "instructions": f"Sign EIP-3009 transfer for ${price_usd} USDC. Addon activates on settlement.", } if req.payment_method == "crypto": return { "success": True, "addon": addon, "addon_id": addon_id, "user_id": user["id"], "payment_details": { "chain": req.crypto_chain, "amount_usd": price_usd, "memo": f"RMI-ADDON-{addon_id}", }, } # Stripe return { "success": True, "addon": addon, "addon_id": addon_id, "user_id": user["id"], "checkout_url": f"/pricing?addon={addon}&period={req.period}", } @router.get("/health") async def health(): return { "status": "ok", "redis": REDIS_OK, "cases_stored": len(_cases_db), "investigators_stored": len(_investigators_db), }