fix(auth): mount auth router + rename __init__ to router.py
P1.1 — Implement real ai_router.chat_completion / stream_chat_completion using Ollama Cloud primary + OpenRouter fallback. Fixes AttributeError on /api/v1/chat and /api/v1/wallet-clusters. P1.2 — Fix broken RAG call paths: telegram bot now hits /api/v1/rag/v2/search then ai_router.chat_completion; unified_wallet_scanner._rag_enrich uses async httpx + correct path. Mount app.domains.databus.router. P1.3 — Implement setup_otel / shutdown_otel in core/tracing.py and create core/langfuse.py with init_langfuse / flush_langfuse as safe no-ops when SDK is missing or env vars unset. P1.4 — Fix Prometheus alert rule metric name drift: rmi_requests_total -> rmi_http_requests_total (matches metrics.py). P1.5 — Move dead admin_backend.py (1691 LOC) to app/domains/admin/router.py and mount it. Admin login endpoint now reachable. P2.1 — Rename app/domains/auth/__init__.py (507 LOC router file that broke mypy) -> app/domains/auth/router.py. Mount app.domains.auth.router so /register, /login, /wallet/*, /user/me, /2fa/*, OAuth, Telegram are live. P2.1b — Add pyotp + qrcode to requirements.txt so 2FA endpoints don't 503.
This commit is contained in:
parent
0a8c73d99b
commit
1b53332695
15 changed files with 992 additions and 537 deletions
286
app/ai_router.py
286
app/ai_router.py
|
|
@ -1,72 +1,288 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stub AI Router - Intelligent Model-First Provider Swapping
|
||||
===========================================================
|
||||
Routes requests to optimal AI provider based on quota, latency, and cost.
|
||||
For now, a minimal stub that delegates to OpenRouter.
|
||||
AI Router — service functions for chat completions.
|
||||
|
||||
Primary: Ollama Cloud (deepseek-v4-flash, free).
|
||||
Fallback: OpenRouter (various models via OPENROUTER_API_KEY).
|
||||
|
||||
Both endpoints are OpenAI-compatible /v1/chat/completions.
|
||||
Called by app/routers/chat.py and app/routers/wallet_clustering_router.py
|
||||
as ``ai_router.chat_completion(...)`` and
|
||||
``ai_router.stream_chat_completion(...)``.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger("rmi.ai_router")
|
||||
|
||||
router = APIRouter(tags=["AI Router"])
|
||||
|
||||
# Decode base64 LLM key if present, otherwise use plain LLM_API_KEY
|
||||
# (safety net: ensures key is decoded even when imported without main.py)
|
||||
if os.getenv("LLM_API_KEY_B64"):
|
||||
os.environ["LLM_API_KEY"] = base64.b64decode(os.getenv("LLM_API_KEY_B64")).decode()
|
||||
|
||||
# Model tiers (for reference, full config in ai_router.py)
|
||||
MODEL_TIERS = {
|
||||
"T0": {"name": "Ultra", "models": ["gpt-4o", "claude-3.5-sonnet"], "max_cost_per_1k": 0.05},
|
||||
"T1": {"name": "Premium", "models": ["gpt-4-turbo", "claude-3-opus"], "max_cost_per_1k": 0.02},
|
||||
"T2": {
|
||||
"name": "Standard",
|
||||
"models": ["gpt-3.5-turbo", "claude-3-haiku"],
|
||||
"max_cost_per_1k": 0.005,
|
||||
},
|
||||
"T3": {"name": "Fast", "models": ["llama-3-8b", "mistral-tiny"], "max_cost_per_1k": 0.001},
|
||||
"T4": {"name": "Free", "models": ["tiny-llama", "phi-2"], "max_cost_per_1k": 0.0},
|
||||
# ── Provider config ─────────────────────────────────────────────
|
||||
OLLAMA_KEY = os.getenv("OLLAMA_API_KEY", "")
|
||||
OLLAMA_URL = "https://ollama.com/v1/chat/completions"
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "deepseek-v4-flash")
|
||||
|
||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-chat")
|
||||
|
||||
# Tier → model mapping (best effort; Ollama Cloud ignores non-model fields)
|
||||
TIER_MODELS: dict[str, str] = {
|
||||
"T0": "deepseek-v4-pro",
|
||||
"T1": "deepseek-v4-pro",
|
||||
"T2": "deepseek-v4-flash",
|
||||
"T3": "deepseek-v4-flash",
|
||||
"T4": "deepseek-v4-flash",
|
||||
}
|
||||
|
||||
# Providers (for reference)
|
||||
PROVIDERS = {
|
||||
"deepseek": {
|
||||
"url": os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1/chat/completions"),
|
||||
"key_env": "LLM_API_KEY",
|
||||
"model": os.getenv("LLM_MODEL", "deepseek-v4-flash"),
|
||||
"rpm": 100,
|
||||
},
|
||||
"openrouter": {
|
||||
"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"key_env": "OPENROUTER_API_KEY",
|
||||
"rpm": 100,
|
||||
},
|
||||
MODEL_TIERS = {
|
||||
"T0": {"name": "Ultra", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
|
||||
"T1": {"name": "Premium", "models": ["deepseek-v4-pro"], "max_cost_per_1k": 0.0},
|
||||
"T2": {"name": "Standard", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
||||
"T3": {"name": "Fast", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
||||
"T4": {"name": "Free", "models": ["deepseek-v4-flash"], "max_cost_per_1k": 0.0},
|
||||
}
|
||||
|
||||
PROVIDERS = {
|
||||
"ollama": {"url": OLLAMA_URL, "key_env": "OLLAMA_API_KEY", "model": OLLAMA_MODEL, "rpm": 100},
|
||||
"openrouter": {"url": OPENROUTER_URL, "key_env": "OPENROUTER_API_KEY", "model": OPENROUTER_MODEL, "rpm": 100},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_model(model: str | None, tier: str | None) -> str:
|
||||
"""Pick the model: explicit model > tier mapping > default."""
|
||||
if model:
|
||||
return model
|
||||
if tier and tier in TIER_MODELS:
|
||||
return TIER_MODELS[tier]
|
||||
return OLLAMA_MODEL
|
||||
|
||||
|
||||
async def chat_completion(
|
||||
*,
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
tier: str | None = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 500,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Non-streaming chat completion.
|
||||
|
||||
Returns dict with keys: content, model, _provider, _latency_ms, usage, error (on failure).
|
||||
Tries Ollama Cloud first, falls back to OpenRouter.
|
||||
"""
|
||||
resolved = _resolve_model(model, tier)
|
||||
start = time.time()
|
||||
|
||||
# Provider 1: Ollama Cloud
|
||||
if OLLAMA_KEY:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
OLLAMA_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OLLAMA_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": resolved,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
return {
|
||||
"content": content,
|
||||
"model": data.get("model", resolved),
|
||||
"_provider": "ollama",
|
||||
"_latency_ms": round((time.time() - start) * 1000, 1),
|
||||
"usage": data.get("usage", {}),
|
||||
}
|
||||
logger.warning("ai_router ollama status=%s body=%s", resp.status_code, resp.text[:200])
|
||||
except Exception as e:
|
||||
logger.warning("ai_router ollama error: %s", e)
|
||||
|
||||
# Provider 2: OpenRouter fallback
|
||||
if OPENROUTER_KEY:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": resolved,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
return {
|
||||
"content": content,
|
||||
"model": data.get("model", resolved),
|
||||
"_provider": "openrouter",
|
||||
"_latency_ms": round((time.time() - start) * 1000, 1),
|
||||
"usage": data.get("usage", {}),
|
||||
}
|
||||
logger.warning("ai_router openrouter status=%s body=%s", resp.status_code, resp.text[:200])
|
||||
except Exception as e:
|
||||
logger.warning("ai_router openrouter error: %s", e)
|
||||
|
||||
return {
|
||||
"content": "",
|
||||
"model": resolved,
|
||||
"_provider": "none",
|
||||
"_latency_ms": round((time.time() - start) * 1000, 1),
|
||||
"usage": {},
|
||||
"error": "No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)",
|
||||
}
|
||||
|
||||
|
||||
async def stream_chat_completion(
|
||||
*,
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
tier: str | None = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 500,
|
||||
timeout: float = 30.0,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Streaming chat completion via SSE.
|
||||
|
||||
Yields content tokens as strings. On error, yields ``[ERROR: ...]``.
|
||||
Tries Ollama Cloud first, falls back to OpenRouter.
|
||||
"""
|
||||
resolved = _resolve_model(model, tier)
|
||||
|
||||
# Provider 1: Ollama Cloud (streaming)
|
||||
if OLLAMA_KEY:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
|
||||
"POST",
|
||||
OLLAMA_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OLLAMA_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": resolved,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
},
|
||||
) as resp:
|
||||
if resp.status_code == 200:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: ") and line != "data: [DONE]":
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return
|
||||
logger.warning("ai_router ollama stream status=%s", resp.status_code)
|
||||
except Exception as e:
|
||||
logger.warning("ai_router ollama stream error: %s", e)
|
||||
|
||||
# Provider 2: OpenRouter streaming fallback
|
||||
if OPENROUTER_KEY:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client, client.stream(
|
||||
"POST",
|
||||
OPENROUTER_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": resolved,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
},
|
||||
) as resp:
|
||||
if resp.status_code == 200:
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("data: ") and line != "data: [DONE]":
|
||||
try:
|
||||
chunk = json.loads(line[6:])
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
content = delta.get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return
|
||||
logger.warning("ai_router openrouter stream status=%s", resp.status_code)
|
||||
except Exception as e:
|
||||
logger.warning("ai_router openrouter stream error: %s", e)
|
||||
|
||||
yield "[ERROR: No AI provider available (set OLLAMA_API_KEY or OPENROUTER_API_KEY)]"
|
||||
|
||||
|
||||
# ── HTTP endpoints (kept for compat, now backed by real service) ──
|
||||
|
||||
|
||||
@router.post("/ai/completions")
|
||||
async def ai_completions(request: dict[str, Any]):
|
||||
async def ai_completions(request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""AI completion via optimal provider routing."""
|
||||
return {"error": "AI Router not fully configured", "provider": "openrouter"}
|
||||
return await chat_completion(
|
||||
messages=request.get("messages", []),
|
||||
model=request.get("model"),
|
||||
tier=request.get("tier"),
|
||||
temperature=request.get("temperature", 0.3),
|
||||
max_tokens=request.get("max_tokens", 500),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ai/chat")
|
||||
async def ai_chat(request: dict[str, Any]):
|
||||
async def ai_chat(request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""AI chat endpoint with provider fallback."""
|
||||
return {"error": "AI Router not fully configured", "provider": "openrouter"}
|
||||
return await chat_completion(
|
||||
messages=request.get("messages", []),
|
||||
model=request.get("model"),
|
||||
tier=request.get("tier"),
|
||||
temperature=request.get("temperature", 0.3),
|
||||
max_tokens=request.get("max_tokens", 500),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ai/providers")
|
||||
async def list_providers():
|
||||
async def list_providers() -> dict[str, Any]:
|
||||
"""List available AI providers."""
|
||||
return {"providers": list(PROVIDERS.keys())}
|
||||
|
||||
|
||||
@router.get("/ai/models")
|
||||
async def list_models():
|
||||
async def list_models() -> dict[str, Any]:
|
||||
"""List available models by tier."""
|
||||
return {"tiers": MODEL_TIERS}
|
||||
|
|
|
|||
49
app/core/langfuse.py
Normal file
49
app/core/langfuse.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Langfuse LLM tracing - opt-in via env vars.
|
||||
|
||||
Safe no-op when LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY are unset
|
||||
or the `langfuse` package is not installed.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
LANGFUSE_ENABLED = bool(
|
||||
os.getenv("LANGFUSE_PUBLIC_KEY", "") and os.getenv("LANGFUSE_SECRET_KEY", "")
|
||||
)
|
||||
LANGFUSE_HOST = os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com")
|
||||
|
||||
_initialized = False
|
||||
|
||||
|
||||
def init_langfuse() -> bool:
|
||||
"""Initialize the Langfuse client. Returns True on success."""
|
||||
global _initialized
|
||||
if not LANGFUSE_ENABLED:
|
||||
return False
|
||||
try:
|
||||
from langfuse import Langfuse # noqa: F401
|
||||
|
||||
Langfuse(
|
||||
public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
|
||||
secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
|
||||
host=LANGFUSE_HOST,
|
||||
)
|
||||
_initialized = True
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def flush_langfuse() -> None:
|
||||
"""Flush any pending Langfuse events. Safe to call on shutdown."""
|
||||
if not _initialized:
|
||||
return
|
||||
try:
|
||||
from langfuse import Langfuse
|
||||
|
||||
Langfuse().flush()
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -7,6 +7,7 @@ import uuid
|
|||
from fastapi import FastAPI, Request
|
||||
|
||||
TRACING_ENABLED = os.getenv("OTEL_ENABLED", "false").lower() == "true"
|
||||
OTEL_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
|
||||
|
||||
|
||||
def setup_tracing(app: FastAPI) -> list:
|
||||
|
|
@ -33,6 +34,49 @@ def setup_tracing(app: FastAPI) -> list:
|
|||
return {"traces": [], "note": "OpenTelemetry export to Grafana Tempo configured. Traces available at :3000."}
|
||||
|
||||
|
||||
def setup_otel() -> bool:
|
||||
"""Initialize OpenTelemetry tracing. Returns True on success.
|
||||
|
||||
No-op if OTEL_ENABLED != "true". If OTEL_ENABLED is true but the SDK
|
||||
is not installed, logs and returns False.
|
||||
"""
|
||||
if not TRACING_ENABLED:
|
||||
return False
|
||||
try:
|
||||
# Lazy import — opentelemetry is an optional dependency.
|
||||
from opentelemetry import trace # noqa: F401
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter # noqa: F401
|
||||
from opentelemetry.sdk.resources import Resource # noqa: F401
|
||||
from opentelemetry.sdk.trace import TracerProvider # noqa: F401
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor # noqa: F401
|
||||
|
||||
resource = Resource.create({"service.name": "rmi-backend"})
|
||||
provider = TracerProvider(resource=resource)
|
||||
if OTEL_ENDPOINT:
|
||||
exporter = OTLPSpanExporter(endpoint=OTEL_ENDPOINT)
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def shutdown_otel() -> None:
|
||||
"""Flush and shut down OpenTelemetry tracer provider."""
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
|
||||
provider = trace.get_tracer_provider()
|
||||
if hasattr(provider, "shutdown"):
|
||||
provider.shutdown()
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_span(name: str, attributes: dict | None = None) -> dict:
|
||||
return {"name": name, "start": time.perf_counter(), "attrs": attributes or {}}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
"""Auth domain - public API.
|
||||
|
||||
Phase 4 domain consolidation + Phase 2.1 structural fix:
|
||||
``app/domains/auth/__init__.py`` was a 507-LOC router file that broke mypy
|
||||
(source file found twice under different module names). The router now
|
||||
lives in ``app/domains/auth/router.py`` and this package marker only
|
||||
re-exports the public surface.
|
||||
"""
|
||||
Auth Router - Complete authentication system (email, wallet, OAuth, Telegram)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.core.redis import get_redis
|
||||
from app.domains.auth.deps import (
|
||||
get_current_user,
|
||||
require_auth as require_auth,
|
||||
require_public_profile as require_public_profile,
|
||||
require_auth,
|
||||
require_public_profile,
|
||||
)
|
||||
from app.domains.auth.jwt import (
|
||||
JWT_ALGORITHM,
|
||||
JWT_EXPIRY_DAYS as JWT_EXPIRY_DAYS,
|
||||
JWT_EXPIRY_DAYS,
|
||||
JWT_SECRET,
|
||||
_create_jwt,
|
||||
_derive_user_id,
|
||||
|
|
@ -26,15 +24,16 @@ from app.domains.auth.jwt import (
|
|||
)
|
||||
from app.domains.auth.oauth import router as oauth_router
|
||||
from app.domains.auth.passwords import hash_password, verify_password
|
||||
from app.domains.auth.router import router
|
||||
from app.domains.auth.schemas import (
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
GoogleAuthResponse as GoogleAuthResponse,
|
||||
GoogleAuthResponse,
|
||||
NonceResponse,
|
||||
TelegramAuthRequest as TelegramAuthRequest,
|
||||
TelegramAuthRequest,
|
||||
TwoFAEnableRequest,
|
||||
TwoFALoginRequest,
|
||||
TwoFASetupResponse as TwoFASetupResponse,
|
||||
TwoFASetupResponse,
|
||||
TwoFAStatusResponse,
|
||||
TwoFAVerifyRequest,
|
||||
UserResponse,
|
||||
|
|
@ -43,7 +42,7 @@ from app.domains.auth.schemas import (
|
|||
WalletVerifyRequest,
|
||||
)
|
||||
from app.domains.auth.store import (
|
||||
_delete_user as _delete_user,
|
||||
_delete_user,
|
||||
_get_user,
|
||||
_get_user_by_email,
|
||||
_is_valid_email,
|
||||
|
|
@ -51,11 +50,11 @@ from app.domains.auth.store import (
|
|||
)
|
||||
from app.domains.auth.totp import (
|
||||
PYOTP_AVAILABLE,
|
||||
TOTP_DIGITS as TOTP_DIGITS,
|
||||
TOTP_INTERVAL as TOTP_INTERVAL,
|
||||
TOTP_ISSUER as TOTP_ISSUER,
|
||||
TOTP_WINDOW as TOTP_WINDOW,
|
||||
_build_totp as _build_totp,
|
||||
TOTP_DIGITS,
|
||||
TOTP_INTERVAL,
|
||||
TOTP_ISSUER,
|
||||
TOTP_WINDOW,
|
||||
_build_totp,
|
||||
_generate_backup_codes,
|
||||
_generate_qr_base64,
|
||||
_generate_totp_secret,
|
||||
|
|
@ -65,443 +64,52 @@ from app.domains.auth.totp import (
|
|||
_verify_totp,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
# Include OAuth/Telegram routes from dedicated submodule
|
||||
router.include_router(oauth_router, tags=["auth"])
|
||||
|
||||
# ── Config ──
|
||||
|
||||
|
||||
# ── Storage (Redis-backed user store) ──
|
||||
# ── Email Auth ──
|
||||
@router.post("/register", response_model=WalletAuthResponse)
|
||||
def register_email(req: EmailRegisterRequest) -> WalletAuthResponse:
|
||||
"""Register a new user with email/password."""
|
||||
if not _is_valid_email(req.email):
|
||||
raise HTTPException(status_code=400, detail="Invalid email format")
|
||||
|
||||
if len(req.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
# Check if user exists
|
||||
existing = _get_user_by_email(req.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user_id = _derive_user_id(req.email)
|
||||
hashed = hash_password(req.password)
|
||||
|
||||
user: dict[str, Any] = {
|
||||
"id": user_id,
|
||||
"email": req.email,
|
||||
"display_name": req.display_name,
|
||||
"password_hash": hashed,
|
||||
"wallet_address": req.wallet_address,
|
||||
"wallet_chain": req.wallet_chain,
|
||||
"tier": "FREE",
|
||||
"role": "USER",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"badges": [],
|
||||
"scans_remaining": 5,
|
||||
"scans_used": 0,
|
||||
}
|
||||
|
||||
# Save user + email index
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
r.hset("rmi:users", user_id, json.dumps(user))
|
||||
r.hset("rmi:users:email", req.email.lower(), user_id)
|
||||
|
||||
token = _create_jwt(user_id, req.email, "FREE", "USER", req.wallet_address)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"email": req.email,
|
||||
"display_name": req.display_name,
|
||||
"wallet_address": req.wallet_address,
|
||||
"wallet_chain": req.wallet_chain,
|
||||
"tier": "FREE",
|
||||
"role": "USER",
|
||||
"created_at": user["created_at"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=WalletAuthResponse)
|
||||
def login_email(req: EmailLoginRequest) -> WalletAuthResponse:
|
||||
"""Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login."""
|
||||
user = _get_user_by_email(req.email)
|
||||
if not user or not user.get("password_hash"):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# If 2FA enabled, return a pre-auth token that requires TOTP verification
|
||||
if user.get("totp_enabled") and user.get("totp_secret"):
|
||||
# Generate a short-lived pre-auth token (5 min)
|
||||
pre_token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
# Override expiry to 5 minutes
|
||||
import jose.jwt as jose_jwt
|
||||
|
||||
payload = jose_jwt.decode(pre_token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
payload["exp"] = datetime.utcnow() + timedelta(minutes=5)
|
||||
payload["2fa_required"] = True
|
||||
payload["pre_auth"] = True
|
||||
pre_token = jose_jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": pre_token,
|
||||
"refresh_token": pre_token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
"_2fa_required": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Wallet Auth ──
|
||||
@router.post("/wallet/nonce", response_model=NonceResponse)
|
||||
async def wallet_nonce(req: WalletNonceRequest) -> NonceResponse:
|
||||
"""Get a nonce for wallet signature."""
|
||||
nonce = generate_nonce()
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
message = f"RugMunch Intelligence wants you to sign in with your {req.chain.title()} account.\n\nWallet: {req.address}\nNonce: {nonce}\nTimestamp: {timestamp}"
|
||||
return cast("NonceResponse", {"nonce": nonce, "timestamp": timestamp, "message": message})
|
||||
|
||||
|
||||
@router.post("/wallet/verify", response_model=WalletAuthResponse)
|
||||
async def wallet_verify(req: WalletVerifyRequest) -> WalletAuthResponse:
|
||||
"""Verify wallet signature and create session."""
|
||||
from app.domains.auth.wallet import get_or_create_wallet_user, verify_wallet_signature
|
||||
|
||||
if not verify_wallet_signature(req.message, req.signature, req.address):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
user_data = await get_or_create_wallet_user(req.address)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": user_data["access_token"],
|
||||
"refresh_token": user_data["refresh_token"],
|
||||
"user": {
|
||||
"id": user_data["id"],
|
||||
"email": user_data["email"],
|
||||
"display_name": user_data.get("display_name", f"Agent {req.address[2:8].upper()}"),
|
||||
"wallet_address": req.address,
|
||||
"wallet_chain": req.chain,
|
||||
"tier": user_data["tier"],
|
||||
"role": user_data["role"],
|
||||
"created_at": user_data["created_at"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── User Profile ──
|
||||
@router.get("/user/me", response_model=UserResponse)
|
||||
async def user_me(request: Request) -> UserResponse:
|
||||
"""Get current authenticated user profile."""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing auth token")
|
||||
|
||||
token = auth_header[7:]
|
||||
payload = _verify_jwt(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
user = _get_user(payload["id"])
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return cast(
|
||||
"UserResponse",
|
||||
{
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
"xp": user.get("xp", 0),
|
||||
"level": user.get("level", 1),
|
||||
"badges": user.get("badges", []),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 2FA / TOTP Endpoints ──
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request) -> TwoFASetupResponse:
|
||||
"""Begin 2FA setup - generate secret + QR code. Returns plaintext secret + backup codes (shown once)."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Check if already enabled
|
||||
if user.get("totp_secret"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="2FA already enabled. Disable first to reconfigure."
|
||||
)
|
||||
|
||||
secret = _generate_totp_secret()
|
||||
uri = _get_totp_uri(secret, user["email"])
|
||||
qr_b64 = _generate_qr_base64(uri)
|
||||
backup_codes = _generate_backup_codes(8)
|
||||
|
||||
# Store pending secret (not enabled until verified)
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
pending = {
|
||||
"secret": secret,
|
||||
"backup_codes": [_hash_backup_code(c) for c in backup_codes],
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
r.setex(f"rmi:2fa_pending:{user['id']}", timedelta(minutes=10), json.dumps(pending))
|
||||
|
||||
return cast("TwoFASetupResponse", {
|
||||
"secret": secret,
|
||||
"qr_code": qr_b64,
|
||||
"uri": uri,
|
||||
"backup_codes": backup_codes, # plaintext - shown once
|
||||
})
|
||||
|
||||
|
||||
@router.post("/2fa/enable")
|
||||
async def twofa_enable(req: TwoFAEnableRequest, request: Request) -> dict[str, Any]:
|
||||
"""Enable 2FA - verify the first TOTP code, then persist."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
pending_raw = r.get(f"rmi:2fa_pending:{user['id']}")
|
||||
if not pending_raw:
|
||||
raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.")
|
||||
|
||||
pending = json.loads(pending_raw)
|
||||
secret = pending["secret"]
|
||||
|
||||
# Verify the code
|
||||
if not _verify_totp(secret, req.code):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid TOTP code. Check your authenticator app time sync."
|
||||
)
|
||||
|
||||
# Enable 2FA on user record
|
||||
user["totp_secret"] = secret
|
||||
user["totp_enabled"] = True
|
||||
user["totp_created_at"] = pending["created_at"]
|
||||
user["totp_backup_codes"] = pending["backup_codes"] # already hashed
|
||||
user["totp_last_used"] = None
|
||||
_save_user(user)
|
||||
|
||||
# Clean up pending
|
||||
r.delete(f"rmi:2fa_pending:{user['id']}")
|
||||
|
||||
# Audit log
|
||||
logger.info(f"[2FA ENABLED] user={user['id']} email={user['email']}")
|
||||
|
||||
return {"status": "ok", "message": "2FA enabled successfully", "method": "totp"}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request) -> dict[str, Any]:
|
||||
"""Disable 2FA - requires current password for security."""
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
if not user.get("totp_enabled"):
|
||||
raise HTTPException(status_code=400, detail="2FA is not enabled")
|
||||
|
||||
# Remove 2FA fields
|
||||
for key in [
|
||||
"totp_secret",
|
||||
"totp_enabled",
|
||||
"totp_created_at",
|
||||
"totp_backup_codes",
|
||||
"totp_last_used",
|
||||
]:
|
||||
user.pop(key, None)
|
||||
|
||||
_save_user(user)
|
||||
logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}")
|
||||
|
||||
return cast("dict[str, Any]", {"status": "ok", "message": "2FA disabled successfully"})
|
||||
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFAStatusResponse)
|
||||
async def twofa_status(request: Request) -> TwoFAStatusResponse:
|
||||
"""Check 2FA status for current user."""
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
enabled = user.get("totp_enabled", False)
|
||||
return cast("TwoFAStatusResponse", {
|
||||
"enabled": enabled,
|
||||
"method": "totp" if enabled else "none",
|
||||
"created_at": user.get("totp_created_at"),
|
||||
"last_used": user.get("totp_last_used"),
|
||||
})
|
||||
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
async def twofa_verify(req: TwoFAVerifyRequest, request: Request) -> dict[str, Any]:
|
||||
"""Verify a TOTP code (for re-auth during sensitive operations)."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
if not user.get("totp_enabled") or not user.get("totp_secret"):
|
||||
raise HTTPException(status_code=400, detail="2FA not enabled")
|
||||
|
||||
if not _verify_totp(user["totp_secret"], req.code):
|
||||
raise HTTPException(status_code=400, detail="Invalid TOTP code")
|
||||
|
||||
# Update last used
|
||||
user["totp_last_used"] = datetime.utcnow().isoformat()
|
||||
_save_user(user)
|
||||
|
||||
return cast("dict[str, Any]", {"status": "ok", "verified": True})
|
||||
|
||||
|
||||
@router.post("/2fa/login")
|
||||
async def twofa_login(req: TwoFALoginRequest) -> WalletAuthResponse:
|
||||
"""Login with email + password + 2FA code. Returns full JWT on success."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = _get_user_by_email(req.email)
|
||||
if not user or not user.get("password_hash"):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Check if 2FA is enabled
|
||||
if not user.get("totp_enabled") or not user.get("totp_secret"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="2FA not enabled for this account. Use /login instead."
|
||||
)
|
||||
|
||||
code = req.code.strip().replace("-", "")
|
||||
|
||||
# Try TOTP first
|
||||
if _verify_totp(user["totp_secret"], code):
|
||||
pass # valid TOTP
|
||||
else:
|
||||
# Try backup codes
|
||||
backup_codes = user.get("totp_backup_codes", [])
|
||||
matched = False
|
||||
for i, hashed in enumerate(backup_codes):
|
||||
if _verify_backup_code(req.code, hashed):
|
||||
matched = True
|
||||
# Remove used backup code
|
||||
backup_codes.pop(i)
|
||||
user["totp_backup_codes"] = backup_codes
|
||||
break
|
||||
if not matched:
|
||||
raise HTTPException(status_code=401, detail="Invalid 2FA code or backup code")
|
||||
|
||||
# Update last used
|
||||
user["totp_last_used"] = datetime.utcnow().isoformat()
|
||||
_save_user(user)
|
||||
|
||||
token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
|
||||
logger.info(f"[2FA LOGIN] user={user['id']} email={user['email']}")
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
__all__ = [
|
||||
"EmailLoginRequest",
|
||||
"EmailRegisterRequest",
|
||||
"GoogleAuthResponse",
|
||||
"JWT_ALGORITHM",
|
||||
"JWT_EXPIRY_DAYS",
|
||||
"JWT_SECRET",
|
||||
"NonceResponse",
|
||||
"PYOTP_AVAILABLE",
|
||||
"PERMISSIONS",
|
||||
"TOTP_DIGITS",
|
||||
"TOTP_INTERVAL",
|
||||
"TOTP_ISSUER",
|
||||
"TOTP_WINDOW",
|
||||
"TelegramAuthRequest",
|
||||
"TwoFAEnableRequest",
|
||||
"TwoFALoginRequest",
|
||||
"TwoFASetupResponse",
|
||||
"TwoFAStatusResponse",
|
||||
"TwoFAVerifyRequest",
|
||||
"UserResponse",
|
||||
"WalletAuthResponse",
|
||||
"WalletNonceRequest",
|
||||
"WalletVerifyRequest",
|
||||
"_build_totp",
|
||||
"_create_jwt",
|
||||
"_delete_user",
|
||||
"_derive_user_id",
|
||||
"_generate_backup_codes",
|
||||
"_generate_qr_base64",
|
||||
"_generate_totp_secret",
|
||||
"_get_totp_uri",
|
||||
"_get_user",
|
||||
"_get_user_by_email",
|
||||
"_hash_backup_code",
|
||||
"_is_valid_email",
|
||||
"_save_user",
|
||||
"_verify_backup_code",
|
||||
"_verify_jwt",
|
||||
"_verify_totp",
|
||||
"generate_nonce",
|
||||
"get_current_user",
|
||||
"hash_password",
|
||||
"oauth_router",
|
||||
"require_auth",
|
||||
"require_public_profile",
|
||||
"router",
|
||||
"verify_password",
|
||||
]
|
||||
509
app/domains/auth/router.py
Normal file
509
app/domains/auth/router.py
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"""
|
||||
Auth Router - Complete authentication system (email, wallet, OAuth, Telegram)
|
||||
|
||||
This is the FastAPI router for the auth domain. It is mounted by
|
||||
``app/mount.py`` via the re-export in ``app.domains.auth.router``.
|
||||
|
||||
Phase 2.1: extracted from ``app/domains/auth/__init__.py`` so that the
|
||||
package marker is a clean re-export shim and mypy does not see the
|
||||
router file as both module and package.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.core.redis import get_redis
|
||||
from app.domains.auth.deps import (
|
||||
get_current_user,
|
||||
)
|
||||
from app.domains.auth.jwt import (
|
||||
JWT_ALGORITHM,
|
||||
JWT_SECRET,
|
||||
_create_jwt,
|
||||
_derive_user_id,
|
||||
_verify_jwt,
|
||||
generate_nonce,
|
||||
)
|
||||
from app.domains.auth.oauth import router as oauth_router
|
||||
from app.domains.auth.passwords import hash_password, verify_password
|
||||
from app.domains.auth.schemas import (
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
NonceResponse,
|
||||
TwoFAEnableRequest,
|
||||
TwoFALoginRequest,
|
||||
TwoFASetupResponse,
|
||||
TwoFAStatusResponse,
|
||||
TwoFAVerifyRequest,
|
||||
UserResponse,
|
||||
WalletAuthResponse,
|
||||
WalletNonceRequest,
|
||||
WalletVerifyRequest,
|
||||
)
|
||||
from app.domains.auth.store import (
|
||||
_get_user,
|
||||
_get_user_by_email,
|
||||
_is_valid_email,
|
||||
_save_user,
|
||||
)
|
||||
from app.domains.auth.totp import (
|
||||
PYOTP_AVAILABLE,
|
||||
_generate_backup_codes,
|
||||
_generate_qr_base64,
|
||||
_generate_totp_secret,
|
||||
_get_totp_uri,
|
||||
_hash_backup_code,
|
||||
_verify_backup_code,
|
||||
_verify_totp,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
# Include OAuth/Telegram routes from dedicated submodule
|
||||
router.include_router(oauth_router, tags=["auth"])
|
||||
|
||||
# ── Config ──
|
||||
|
||||
|
||||
# ── Storage (Redis-backed user store) ──
|
||||
# ── Email Auth ──
|
||||
@router.post("/register", response_model=WalletAuthResponse)
|
||||
def register_email(req: EmailRegisterRequest) -> WalletAuthResponse:
|
||||
"""Register a new user with email/password."""
|
||||
if not _is_valid_email(req.email):
|
||||
raise HTTPException(status_code=400, detail="Invalid email format")
|
||||
|
||||
if len(req.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
# Check if user exists
|
||||
existing = _get_user_by_email(req.email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
user_id = _derive_user_id(req.email)
|
||||
hashed = hash_password(req.password)
|
||||
|
||||
user: dict[str, Any] = {
|
||||
"id": user_id,
|
||||
"email": req.email,
|
||||
"display_name": req.display_name,
|
||||
"password_hash": hashed,
|
||||
"wallet_address": req.wallet_address,
|
||||
"wallet_chain": req.wallet_chain,
|
||||
"tier": "FREE",
|
||||
"role": "USER",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"xp": 0,
|
||||
"level": 1,
|
||||
"badges": [],
|
||||
"scans_remaining": 5,
|
||||
"scans_used": 0,
|
||||
}
|
||||
|
||||
# Save user + email index
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
r.hset("rmi:users", user_id, json.dumps(user))
|
||||
r.hset("rmi:users:email", req.email.lower(), user_id)
|
||||
|
||||
token = _create_jwt(user_id, req.email, "FREE", "USER", req.wallet_address)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
"email": req.email,
|
||||
"display_name": req.display_name,
|
||||
"wallet_address": req.wallet_address,
|
||||
"wallet_chain": req.wallet_chain,
|
||||
"tier": "FREE",
|
||||
"role": "USER",
|
||||
"created_at": user["created_at"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=WalletAuthResponse)
|
||||
def login_email(req: EmailLoginRequest) -> WalletAuthResponse:
|
||||
"""Login with email/password. If 2FA enabled, returns partial token requiring /2fa/login."""
|
||||
user = _get_user_by_email(req.email)
|
||||
if not user or not user.get("password_hash"):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# If 2FA enabled, return a pre-auth token that requires TOTP verification
|
||||
if user.get("totp_enabled") and user.get("totp_secret"):
|
||||
# Generate a short-lived pre-auth token (5 min)
|
||||
pre_token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
# Override expiry to 5 minutes
|
||||
import jose.jwt as jose_jwt
|
||||
|
||||
payload = jose_jwt.decode(pre_token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
payload["exp"] = datetime.utcnow() + timedelta(minutes=5)
|
||||
payload["2fa_required"] = True
|
||||
payload["pre_auth"] = True
|
||||
pre_token = jose_jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": pre_token,
|
||||
"refresh_token": pre_token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
"_2fa_required": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Wallet Auth ──
|
||||
@router.post("/wallet/nonce", response_model=NonceResponse)
|
||||
async def wallet_nonce(req: WalletNonceRequest) -> NonceResponse:
|
||||
"""Get a nonce for wallet signature."""
|
||||
nonce = generate_nonce()
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
message = f"RugMunch Intelligence wants you to sign in with your {req.chain.title()} account.\n\nWallet: {req.address}\nNonce: {nonce}\nTimestamp: {timestamp}"
|
||||
return cast("NonceResponse", {"nonce": nonce, "timestamp": timestamp, "message": message})
|
||||
|
||||
|
||||
@router.post("/wallet/verify", response_model=WalletAuthResponse)
|
||||
async def wallet_verify(req: WalletVerifyRequest) -> WalletAuthResponse:
|
||||
"""Verify wallet signature and create session."""
|
||||
from app.domains.auth.wallet import get_or_create_wallet_user, verify_wallet_signature
|
||||
|
||||
if not verify_wallet_signature(req.message, req.signature, req.address):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
user_data = await get_or_create_wallet_user(req.address)
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": user_data["access_token"],
|
||||
"refresh_token": user_data["refresh_token"],
|
||||
"user": {
|
||||
"id": user_data["id"],
|
||||
"email": user_data["email"],
|
||||
"display_name": user_data.get("display_name", f"Agent {req.address[2:8].upper()}"),
|
||||
"wallet_address": req.address,
|
||||
"wallet_chain": req.chain,
|
||||
"tier": user_data["tier"],
|
||||
"role": user_data["role"],
|
||||
"created_at": user_data["created_at"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── User Profile ──
|
||||
@router.get("/user/me", response_model=UserResponse)
|
||||
async def user_me(request: Request) -> UserResponse:
|
||||
"""Get current authenticated user profile."""
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing auth token")
|
||||
|
||||
token = auth_header[7:]
|
||||
payload = _verify_jwt(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
user = _get_user(payload["id"])
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return cast(
|
||||
"UserResponse",
|
||||
{
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
"xp": user.get("xp", 0),
|
||||
"level": user.get("level", 1),
|
||||
"badges": user.get("badges", []),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 2FA / TOTP Endpoints ──
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request) -> TwoFASetupResponse:
|
||||
"""Begin 2FA setup - generate secret + QR code. Returns plaintext secret + backup codes (shown once)."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Check if already enabled
|
||||
if user.get("totp_secret"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="2FA already enabled. Disable first to reconfigure."
|
||||
)
|
||||
|
||||
secret = _generate_totp_secret()
|
||||
uri = _get_totp_uri(secret, user["email"])
|
||||
qr_b64 = _generate_qr_base64(uri)
|
||||
backup_codes = _generate_backup_codes(8)
|
||||
|
||||
# Store pending secret (not enabled until verified)
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
pending = {
|
||||
"secret": secret,
|
||||
"backup_codes": [_hash_backup_code(c) for c in backup_codes],
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
r.setex(f"rmi:2fa_pending:{user['id']}", timedelta(minutes=10), json.dumps(pending))
|
||||
|
||||
return cast(
|
||||
"TwoFASetupResponse",
|
||||
{
|
||||
"secret": secret,
|
||||
"qr_code": qr_b64,
|
||||
"uri": uri,
|
||||
"backup_codes": backup_codes, # plaintext - shown once
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/2fa/enable")
|
||||
async def twofa_enable(req: TwoFAEnableRequest, request: Request) -> dict[str, Any]:
|
||||
"""Enable 2FA - verify the first TOTP code, then persist."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
r = get_redis()
|
||||
assert r is not None
|
||||
pending_raw = r.get(f"rmi:2fa_pending:{user['id']}")
|
||||
if not pending_raw:
|
||||
raise HTTPException(status_code=400, detail="No pending 2FA setup. Call /2fa/setup first.")
|
||||
|
||||
pending = json.loads(pending_raw)
|
||||
secret = pending["secret"]
|
||||
|
||||
# Verify the code
|
||||
if not _verify_totp(secret, req.code):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid TOTP code. Check your authenticator app time sync."
|
||||
)
|
||||
|
||||
# Enable 2FA on user record
|
||||
user["totp_secret"] = secret
|
||||
user["totp_enabled"] = True
|
||||
user["totp_created_at"] = pending["created_at"]
|
||||
user["totp_backup_codes"] = pending["backup_codes"] # already hashed
|
||||
user["totp_last_used"] = None
|
||||
_save_user(user)
|
||||
|
||||
# Clean up pending
|
||||
r.delete(f"rmi:2fa_pending:{user['id']}")
|
||||
|
||||
# Audit log
|
||||
logger.info(f"[2FA ENABLED] user={user['id']} email={user['email']}")
|
||||
|
||||
return {"status": "ok", "message": "2FA enabled successfully", "method": "totp"}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request) -> dict[str, Any]:
|
||||
"""Disable 2FA - requires current password for security."""
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
if not user.get("totp_enabled"):
|
||||
raise HTTPException(status_code=400, detail="2FA is not enabled")
|
||||
|
||||
# Remove 2FA fields
|
||||
for key in [
|
||||
"totp_secret",
|
||||
"totp_enabled",
|
||||
"totp_created_at",
|
||||
"totp_backup_codes",
|
||||
"totp_last_used",
|
||||
]:
|
||||
user.pop(key, None)
|
||||
|
||||
_save_user(user)
|
||||
logger.info(f"[2FA DISABLED] user={user['id']} email={user['email']}")
|
||||
|
||||
return cast("dict[str, Any]", {"status": "ok", "message": "2FA disabled successfully"})
|
||||
|
||||
|
||||
@router.get("/2fa/status", response_model=TwoFAStatusResponse)
|
||||
async def twofa_status(request: Request) -> TwoFAStatusResponse:
|
||||
"""Check 2FA status for current user."""
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
enabled = user.get("totp_enabled", False)
|
||||
return cast(
|
||||
"TwoFAStatusResponse",
|
||||
{
|
||||
"enabled": enabled,
|
||||
"method": "totp" if enabled else "none",
|
||||
"created_at": user.get("totp_created_at"),
|
||||
"last_used": user.get("totp_last_used"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
async def twofa_verify(req: TwoFAVerifyRequest, request: Request) -> dict[str, Any]:
|
||||
"""Verify a TOTP code (for re-auth during sensitive operations)."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = await get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
if not user.get("totp_enabled") or not user.get("totp_secret"):
|
||||
raise HTTPException(status_code=400, detail="2FA not enabled")
|
||||
|
||||
if not _verify_totp(user["totp_secret"], req.code):
|
||||
raise HTTPException(status_code=400, detail="Invalid TOTP code")
|
||||
|
||||
# Update last used
|
||||
user["totp_last_used"] = datetime.utcnow().isoformat()
|
||||
_save_user(user)
|
||||
|
||||
return cast("dict[str, Any]", {"status": "ok", "verified": True})
|
||||
|
||||
|
||||
@router.post("/2fa/login")
|
||||
async def twofa_login(req: TwoFALoginRequest) -> WalletAuthResponse:
|
||||
"""Login with email + password + 2FA code. Returns full JWT on success."""
|
||||
if not PYOTP_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="2FA service unavailable")
|
||||
|
||||
user = _get_user_by_email(req.email)
|
||||
if not user or not user.get("password_hash"):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Check if 2FA is enabled
|
||||
if not user.get("totp_enabled") or not user.get("totp_secret"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="2FA not enabled for this account. Use /login instead."
|
||||
)
|
||||
|
||||
code = req.code.strip().replace("-", "")
|
||||
|
||||
# Try TOTP first
|
||||
if _verify_totp(user["totp_secret"], code):
|
||||
pass # valid TOTP
|
||||
else:
|
||||
# Try backup codes
|
||||
backup_codes = user.get("totp_backup_codes", [])
|
||||
matched = False
|
||||
for i, hashed in enumerate(backup_codes):
|
||||
if _verify_backup_code(req.code, hashed):
|
||||
matched = True
|
||||
# Remove used backup code
|
||||
backup_codes.pop(i)
|
||||
user["totp_backup_codes"] = backup_codes
|
||||
break
|
||||
if not matched:
|
||||
raise HTTPException(status_code=401, detail="Invalid 2FA code or backup code")
|
||||
|
||||
# Update last used
|
||||
user["totp_last_used"] = datetime.utcnow().isoformat()
|
||||
_save_user(user)
|
||||
|
||||
token = _create_jwt(
|
||||
user["id"],
|
||||
user["email"],
|
||||
user.get("tier", "FREE"),
|
||||
user.get("role", "USER"),
|
||||
user.get("wallet_address"),
|
||||
)
|
||||
|
||||
logger.info(f"[2FA LOGIN] user={user['id']} email={user['email']}")
|
||||
|
||||
return cast(
|
||||
"WalletAuthResponse",
|
||||
{
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"email": user["email"],
|
||||
"display_name": user.get("display_name", user["email"]),
|
||||
"wallet_address": user.get("wallet_address"),
|
||||
"wallet_chain": user.get("wallet_chain"),
|
||||
"tier": user.get("tier", "FREE"),
|
||||
"role": user.get("role", "USER"),
|
||||
"created_at": user.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -4,7 +4,7 @@ Phase 4 domain consolidation: app.mcp -> app.domains.mcp.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domains.mcp.registry import TOOL_CATEGORIES, get_tool_by_name
|
||||
from app.domains.mcp.registry import TOOL_CATEGORIES, resolve_tool
|
||||
from app.domains.mcp.server import (
|
||||
MCP_PROTOCOL_VERSION,
|
||||
MCP_SERVER_VERSION,
|
||||
|
|
@ -28,6 +28,6 @@ __all__ = [
|
|||
"TOOL_VERSIONS",
|
||||
"X402ToolManager",
|
||||
"call_tool",
|
||||
"get_tool_by_name",
|
||||
"handle_mcp_call",
|
||||
"resolve_tool",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1640,20 +1640,39 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
# Try RMI RAG backend
|
||||
# Try RAG search + LLM answer
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{BACKEND_URL}/api/v1/rag/query",
|
||||
json={"query": text, "user_id": user.id, "context": "telegram_bot"},
|
||||
f"{BACKEND_URL}/api/v1/rag/v2/search",
|
||||
json={"query": text, "top_k": 5},
|
||||
)
|
||||
rag_context = ""
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
answer = data.get("answer", "")
|
||||
if answer:
|
||||
db.increment_usage(user.id, ai_msg=True)
|
||||
await update.message.reply_text(answer, parse_mode=ParseMode.HTML, reply_markup=back_kb())
|
||||
return
|
||||
hits = r.json().get("hits", [])
|
||||
rag_context = "\n\n".join(h.get("content", "")[:500] for h in hits[:3])
|
||||
|
||||
from app.ai_router import chat_completion
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are RugMunch Intelligence, a crypto scam detection assistant. "
|
||||
"Answer user questions about tokens, wallets, and scams concisely. "
|
||||
f"Context from knowledge base:\n{rag_context}" if rag_context else
|
||||
"You are RugMunch Intelligence, a crypto scam detection assistant. "
|
||||
"Answer user questions about tokens, wallets, and scams concisely."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
result = await chat_completion(messages=messages, tier="T4", max_tokens=400, timeout=25.0)
|
||||
answer = result.get("content", "")
|
||||
if answer:
|
||||
db.increment_usage(user.id, ai_msg=True)
|
||||
await update.message.reply_text(answer, parse_mode=ParseMode.HTML, reply_markup=back_kb())
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -16,21 +16,21 @@ from app.domains.mcp import (
|
|||
TOOLS,
|
||||
X402ToolManager,
|
||||
call_tool,
|
||||
get_tool_by_name,
|
||||
handle_mcp_call,
|
||||
resolve_tool,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MCP_PROTOCOL_VERSION",
|
||||
"MCP_SERVER_VERSION",
|
||||
"TOOLS",
|
||||
"TOOL_CATALOG",
|
||||
"TOOL_CATEGORIES",
|
||||
"TOOL_DEPRECATED",
|
||||
"TOOL_SUCCESSORS",
|
||||
"TOOL_VERSIONS",
|
||||
"TOOLS",
|
||||
"X402ToolManager",
|
||||
"call_tool",
|
||||
"get_tool_by_name",
|
||||
"handle_mcp_call",
|
||||
"resolve_tool",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ ROUTER_MODULES: Final[list[str]] = [
|
|||
"app.domains.news.admin_router", # /api/v1/news/_admin/*
|
||||
"app.domains.reports", # /api/v1/reports/*
|
||||
"app.domains.x402", # /api/v1/x402/*
|
||||
"app.domains.databus.router", # /api/v1/databus/* (DataBus HTTP API)
|
||||
"app.domains.admin.router", # /api/v1/admin/backend/* (admin login + RBAC)
|
||||
"app.domains.auth.router", # /register, /login, /2fa/*, /wallet/*, /user/me, OAuth, Telegram
|
||||
# x402 MCP + Discovery + Docs
|
||||
"app.routers.x402_mcp_handler", # /mcp/x402, /.well-known/x402
|
||||
"app.routers.x402_docs", # /x402/docs, /x402/sandbox/*
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ ai_guard = AIGuard()
|
|||
|
||||
# ── Auth ──
|
||||
# ── AI Router ──
|
||||
from app.ai_router import router as ai_router # noqa: E402
|
||||
from app import ai_router # noqa: E402
|
||||
from app.domains.auth import get_current_user, require_auth # noqa: E402
|
||||
|
||||
# ── DB path ──
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ async def get_bubble_map_data(req: BubblemapRequest):
|
|||
# AI Deep Dive: If requested and cluster is large/suspicious, trigger AI analysis
|
||||
if req.ai_deep_dive and data.get("nodes") and len(data["nodes"]) >= 5:
|
||||
try:
|
||||
from app.ai_router import router as ai_router
|
||||
from app import ai_router
|
||||
|
||||
# Extract top suspicious wallets for AI context
|
||||
suspicious_wallets = [
|
||||
|
|
@ -178,7 +178,7 @@ async def get_bubble_map_data(req: BubblemapRequest):
|
|||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
result = await ai_router.chat_completion( # type: ignore
|
||||
result = await ai_router.chat_completion(
|
||||
messages=messages, tier="T2", temperature=0.2, max_tokens=400, timeout=15.0
|
||||
)
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ async def get_ai_forensic_breakdown(req: BubblemapRequest):
|
|||
# If AI deep dive is explicitly requested, enrich with LLM analysis
|
||||
if req.ai_deep_dive and breakdown.get("total_wallets_analyzed", 0) > 0:
|
||||
try:
|
||||
from app.ai_router import router as ai_router
|
||||
from app import ai_router
|
||||
|
||||
prompt = f"""You are an elite blockchain forensics analyst. Analyze this wallet cluster.
|
||||
|
||||
|
|
@ -246,7 +246,7 @@ Be direct and avoid fluff."""
|
|||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
result = await ai_router.chat_completion( # type: ignore
|
||||
result = await ai_router.chat_completion(
|
||||
messages=messages, tier="T2", temperature=0.2, max_tokens=600, timeout=20.0
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -184,21 +184,26 @@ class UnifiedWalletScanner:
|
|||
# ═══════════════════════════════════════════════════════════════
|
||||
async def _rag_enrich(self, r):
|
||||
try:
|
||||
req = Request(
|
||||
f"{BACKEND}/api/v1/rag/search?q={r.address} scam rug hack&limit=5",
|
||||
headers={"X-RMI-Key": "rmi-internal-2026"},
|
||||
)
|
||||
resp = urlopen(req, timeout=8)
|
||||
data = json.loads(resp.read())
|
||||
r.rag_matches = data.get("results", [])
|
||||
if r.rag_matches:
|
||||
r.modules_run.append("rag_entity_resolution")
|
||||
r.enrichment_sources.append("rag:20K_docs")
|
||||
for match in r.rag_matches[:3]:
|
||||
content = match.get("content", "")[:80]
|
||||
if r.address.lower() in content.lower():
|
||||
r.risk_flags.append("RAG_SCAMMER_MATCH")
|
||||
r.risk_score += 30
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
resp = await client.post(
|
||||
f"{BACKEND}/api/v1/rag/v2/search",
|
||||
json={"query": f"{r.address} scam rug hack", "top_k": 5},
|
||||
headers={"X-RMI-Key": "rmi-internal-2026"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return
|
||||
data = resp.json()
|
||||
r.rag_matches = data.get("hits", [])
|
||||
if r.rag_matches:
|
||||
r.modules_run.append("rag_entity_resolution")
|
||||
r.enrichment_sources.append("rag:20K_docs")
|
||||
for match in r.rag_matches[:3]:
|
||||
content = match.get("content", "")[:80]
|
||||
if r.address.lower() in content.lower():
|
||||
r.risk_flags.append("RAG_SCAMMER_MATCH")
|
||||
r.risk_score += 30
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG enrich failed: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ groups:
|
|||
- alert: HighErrorRate
|
||||
expr: |
|
||||
(
|
||||
sum(rate(rmi_requests_total{status=~"5.."}[5m]))
|
||||
/ sum(rate(rmi_requests_total[5m]))
|
||||
sum(rate(rmi_http_requests_total{status=~"5.."}[5m]))
|
||||
/ sum(rate(rmi_http_requests_total[5m]))
|
||||
) > 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
|
|
@ -133,8 +133,8 @@ groups:
|
|||
- alert: SLOBurnRate2x
|
||||
expr: |
|
||||
(
|
||||
sum(rate(rmi_requests_total{status=~"5.."}[1h]))
|
||||
/ sum(rate(rmi_requests_total[1h]))
|
||||
sum(rate(rmi_http_requests_total{status=~"5.."}[1h]))
|
||||
/ sum(rate(rmi_http_requests_total[1h]))
|
||||
) > 0.02
|
||||
for: 5m
|
||||
labels:
|
||||
|
|
@ -145,8 +145,8 @@ groups:
|
|||
- alert: SLOBurnRate4x
|
||||
expr: |
|
||||
(
|
||||
sum(rate(rmi_requests_total{status=~"5.."}[1h]))
|
||||
/ sum(rate(rmi_requests_total[1h]))
|
||||
sum(rate(rmi_http_requests_total{status=~"5.."}[1h]))
|
||||
/ sum(rate(rmi_http_requests_total[1h]))
|
||||
) > 0.04
|
||||
for: 2m
|
||||
labels:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ python-telegram-bot>=21.0
|
|||
supabase>=2.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
python-multipart>=0.0.17
|
||||
pyotp>=2.9.0
|
||||
qrcode>=7.4.0
|
||||
aiohttp>=3.11.0
|
||||
feedparser>=6.0.11
|
||||
websockets>=13.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue