diff --git a/.env.example b/.env.example index eaccfa3..ff8f933 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,8 @@ DATABASE_URL=postgresql+asyncpg://trusthire:trusthire@localhost:5432/trusthire REDIS_URL=redis://localhost:6379 # Stripe — https://dashboard.stripe.com/apikeys -STRIPE_SECRET_KEY=sk_test_YOUR_STRIPE_SECRET_KEY +# If you accidentally exposed live keys, rotate them immediately in Stripe Dashboard. +STRIPE_SECRET_KEY=sk_live_OR_sk_test_YOUR_STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_STRIPE_PUBLISHABLE_KEY STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET @@ -36,3 +37,10 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:8000,https://felipeofdev-ai. SENTRY_DSN= LOG_LEVEL=INFO LOG_FORMAT=json + +# OpenAI (GPT/Codex Router) +OPENAI_API_KEY= + +STRIPE_SUCCESS_URL=https://trusthire.ai/success +STRIPE_CANCEL_URL=https://trusthire.ai/cancel +STRIPE_TRIAL_DAYS=7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9054ffa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: ["main", "work"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + - name: Run tests + run: pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea0e854 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Secrets and infra state +.env +*.pem +secrets/ +weights/ +terraform.tfstate +terraform.tfstate.* + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Node +node_modules/ diff --git a/README.md b/README.md index 53ce430..1c33eeb 100644 --- a/README.md +++ b/README.md @@ -210,3 +210,107 @@ Contributions welcome! Please: --- **Built with ❤️ to protect job seekers from scams** + + +## ✅ Production Readiness Checklist + +- Deploy público online: Railway (`render.yaml`/`railway.toml`) e health endpoint `/health`. +- Login real: JWT com refresh token e API keys. +- Banco real: suporte a PostgreSQL via `DATABASE_URL`. +- API documentada: Swagger em `/api/v1/docs`. +- Testes unitários: suíte `tests/` + CI em `.github/workflows/ci.yml`. +- Monitoramento e métricas: endpoint `/metrics` com uptime, latência média/p95, RPM e taxa de erro. +- Segurança básica: rate limiting, headers CSP/XSS, sanitização server-side e hash Argon2. + +## Stripe (modo real) +1. No Dashboard Stripe, trocar para **Live mode**. +2. Configurar variáveis no Railway: + - `STRIPE_SECRET_KEY` + - `STRIPE_PUBLISHABLE_KEY` + - `STRIPE_WEBHOOK_SECRET` +3. Criar produtos/preços live e preencher os `STRIPE_PRICE_*`. +4. Validar checkout no endpoint `/api/v1/billing`. + +## Railway (produção) +1. Definir variáveis de ambiente de produção (`ENV=prod`, `SECRET_KEY`, `DATABASE_URL`, `REDIS_URL`). +2. Garantir `CORS_ORIGINS_STR` com domínio público do frontend. +3. Monitorar `/health` e `/metrics` no painel de observabilidade. + + +## 🚀 Deploy de Produção (script completo) + +```bash +bash scripts/deploy_production.sh +``` + +Variáveis opcionais: +- `REGISTRY_URI` (ex: ECR/GHCR) +- `IMAGE_TAG` (default: `latest`) + +## 📈 Monitoramento pronto + Dashboard pronto + +```bash +docker compose -f docker-compose.monitoring.yml up -d +``` + +Acessos: +- Prometheus: `http://localhost:9090` +- Grafana: `http://localhost:3001` (`admin/admin`) +- Loki: `http://localhost:3100` + +Métricas Prometheus da API: +- `GET /metrics/prometheus` + +## ⚡ Load test pronto + +```bash +bash scripts/run_load_test.sh http://localhost:8000 +``` + +Saída de benchmark: +- `loadtest/results/benchmark.md` +- CSVs do Locust em `loadtest/results/` + + +## 💳 Stripe Live (produção) + +Guia operacional completo em `docs/STRIPE_LIVE_SETUP.md`. + +Script para criação de produtos/preços live: +```bash +export STRIPE_SECRET_KEY=sk_live_... +bash scripts/stripe_setup_live.sh +``` + + +Teste local do webhook com Stripe CLI: +```bash +bash scripts/stripe_webhook_local_test.sh http://localhost:8000 +``` + + +## 🌐 Provas Públicas de Senioridade + +### Demo pública +- Live Demo: `https://app.trusthire.ai` (configure apontamento para seu deploy em produção) + +### Screenshot do dashboard +- Suba a stack de monitoramento: +```bash +docker compose -f docker-compose.monitoring.yml up -d +``` +- Acesse Grafana em `http://localhost:3001` e exporte screenshot do dashboard "TrustHire Production Overview". + +### Benchmark publicado +```bash +bash scripts/run_load_test.sh http://localhost:8000 +python scripts/generate_benchmark_summary.py +``` +- Resultado público em: `docs/BENCHMARK_PUBLIC.md` + +### Architecture diagram +- Arquitetura: `docs/ARCHITECTURE_DIAGRAM.md` +- Diagrama SVG: `docs/architecture.svg` + +### Case study técnico +- Documento: `docs/CASE_STUDY_TECHNICAL.md` diff --git a/README_INTEGRACAO.md b/README_INTEGRACAO.md index 1f838ae..62ac576 100644 --- a/README_INTEGRACAO.md +++ b/README_INTEGRACAO.md @@ -1,5 +1,23 @@ # 🚀 TrustHire - Guia Rápido de Integração + +## ✅ Integração automática (recomendado) + +No repositório `trusthire`, execute: + +```bash +bash scripts/link_trusthire_ecossistema.sh +``` + +Esse script: +- clona/atualiza `trusthire-backend` e `trusthire-frontend` como diretórios irmãos; +- ajusta `ALLOWED_ORIGINS` no backend para aceitar frontend React e `index.html`; +- cria `trusthire-frontend/.env.local` apontando para `http://localhost:8000/api/v1`. + +Depois, suba os 3 serviços conforme instruções exibidas pelo script. + +--- + ## ⚡ Setup em 5 Minutos ### 1️⃣ Clone os 3 Repositórios diff --git a/ai/router.py b/ai/router.py new file mode 100644 index 0000000..c9411af --- /dev/null +++ b/ai/router.py @@ -0,0 +1,31 @@ +"""Multi-provider AI router (Claude + GPT + Codex) with safe fallback.""" + +from __future__ import annotations + +from typing import Literal + +from config import settings + +TaskType = Literal["resume", "scam", "code"] + + +def ai_router(task: TaskType, prompt: str) -> dict: + """ + Route task to preferred provider. + This keeps production routing explicit while allowing env-based gradual rollout. + """ + provider = "fallback" + + if task == "scam" and settings.ANTHROPIC_API_KEY: + provider = "claude" + elif task == "resume" and settings.OPENAI_API_KEY: + provider = "gpt" + elif task == "code" and settings.OPENAI_API_KEY: + provider = "codex" + + # Controlled fallback (no external call in offline/dev env) + return { + "provider": provider, + "task": task, + "response": f"[{provider}] {prompt[:400]}", + } diff --git a/api/analysis.py b/api/analysis.py index c97bfbf..103e322 100644 --- a/api/analysis.py +++ b/api/analysis.py @@ -3,7 +3,7 @@ Core analysis endpoints with per-tier rate limiting """ -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request from models.schemas import AnalysisRequest, AnalysisResult from models.user_models import TokenData, UserTier @@ -11,6 +11,8 @@ from auth.auth_service import get_current_user from database.user_repository import UserRepository from utils.logger import get_logger +from utils.security import sanitize_user_text +from utils.audit import log_audit_event logger = get_logger("api.analysis") router = APIRouter() @@ -40,6 +42,7 @@ async def enforce_rate_limit(user: TokenData) -> None: async def analyze_message( request: AnalysisRequest, user: TokenData = Depends(get_current_user), + http_request: Request = None, ): """ Analyze a recruitment message for scam indicators. @@ -58,6 +61,7 @@ async def analyze_message( try: analyzer = get_analyzer() + request.text = sanitize_user_text(request.text) result = await analyzer.analyze( text=request.text, include_ai=request.include_ai_analysis and user.tier != UserTier.FREE, @@ -67,6 +71,7 @@ async def analyze_message( if user.user_id != "anonymous": db = UserRepository() await db.increment_analysis_count(user.user_id) + log_audit_event("analysis.run", user.user_id, request=http_request) return result except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/api/auth.py b/api/auth.py index 65d2e60..8e34e13 100644 --- a/api/auth.py +++ b/api/auth.py @@ -6,7 +6,7 @@ from datetime import timedelta from typing import Optional -from fastapi import APIRouter, HTTPException, Depends, status, BackgroundTasks +from fastapi import APIRouter, HTTPException, Depends, status, BackgroundTasks, Request from fastapi.security import HTTPAuthorizationCredentials from models.user_models import ( @@ -22,6 +22,8 @@ ) from database.user_repository import UserRepository from utils.logger import get_logger +from utils.audit import log_audit_event +from utils.security import sanitize_user_text logger = get_logger("api.auth") router = APIRouter(prefix="/auth", tags=["Authentication"]) @@ -32,6 +34,7 @@ async def register( request: RegisterRequest, background_tasks: BackgroundTasks, + http_request: Request, ): """ Register a new user account. @@ -49,10 +52,11 @@ async def register( ) # Create user + safe_name = sanitize_user_text(request.name) hashed = hash_password(request.password) user = await db.create_user( email=request.email, - name=request.name, + name=safe_name, hashed_password=hashed, ) @@ -66,6 +70,7 @@ async def register( refresh_token = create_refresh_token(user.id) logger.info("user_registered", extra={"user_id": user.id}) + log_audit_event("auth.register", user.id, request=http_request) return Token( access_token=access_token, @@ -77,7 +82,7 @@ async def register( # ==================== LOGIN ==================== @router.post("/login", response_model=Token) -async def login(request: LoginRequest): +async def login(request: LoginRequest, http_request: Request): """ Login with email and password. @@ -105,6 +110,7 @@ async def login(request: LoginRequest): refresh_token = create_refresh_token(user.id) logger.info("user_login", extra={"user_id": user.id, "tier": user.tier.value}) + log_audit_event("auth.login", user.id, request=http_request) return Token( access_token=access_token, @@ -166,6 +172,7 @@ async def get_me(user=Depends(require_auth)): async def change_password( request: ChangePasswordRequest, user=Depends(require_auth), + http_request: Request = None, ): """Change account password""" db = UserRepository() @@ -182,6 +189,7 @@ async def change_password( new_hash = hash_password(request.new_password) await db.update_password(user.user_id, new_hash) logger.info("password_changed", extra={"user_id": user.user_id}) + log_audit_event("auth.change_password", user.user_id, request=http_request) # ==================== API KEYS ==================== @@ -190,6 +198,7 @@ async def change_password( async def create_api_key( request: APIKeyCreate, user=Depends(require_auth), + http_request: Request = None, ): """ Generate a new API key for programmatic access. @@ -205,6 +214,7 @@ async def create_api_key( from datetime import datetime logger.info("api_key_generated", extra={"user_id": user.user_id}) + log_audit_event("auth.api_key_generated", user.user_id, request=http_request) return APIKeyResponse( key=raw_key, @@ -215,8 +225,9 @@ async def create_api_key( @router.delete("/api-keys", status_code=status.HTTP_204_NO_CONTENT) -async def revoke_api_key(user=Depends(require_auth)): +async def revoke_api_key(user=Depends(require_auth), http_request: Request = None): """Revoke current API key""" db = UserRepository() await db.update_api_key(user.user_id, None) logger.info("api_key_revoked", extra={"user_id": user.user_id}) + log_audit_event("auth.api_key_revoked", user.user_id, request=http_request) diff --git a/api/billing.py b/api/billing.py index 1023c12..4c6c802 100644 --- a/api/billing.py +++ b/api/billing.py @@ -1,31 +1,37 @@ -""" -TrustHire Billing API -Stripe integration — gracefully disabled if STRIPE_SECRET_KEY not set -""" +"""TrustHire Billing API - Stripe checkout, portal and webhook.""" -from fastapi import APIRouter, HTTPException, Request, Depends, Header -from fastapi.responses import JSONResponse +from __future__ import annotations + +from datetime import datetime from typing import Optional +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +from auth.auth_service import require_auth +from config import settings +from database.user_repository import UserRepository from models.user_models import ( - CheckoutRequest, CheckoutResponse, - PortalRequest, PortalResponse, - SubscriptionInfo, PlanInfo, UserTier, + CheckoutRequest, + CheckoutResponse, + PlanInfo, + PortalRequest, + PortalResponse, + SubscriptionInfo, SubscriptionStatus, + UserTier, ) -from auth.auth_service import require_auth -from database.user_repository import UserRepository -from config import settings from utils.logger import get_logger logger = get_logger("api.billing") router = APIRouter(prefix="/billing", tags=["Billing"]) +_processed_webhook_events: set[str] = set() -# ── Stripe optional ───────────────────────────────────────────────────────── _stripe = None if settings.STRIPE_SECRET_KEY: try: import stripe as _stripe_module + _stripe_module.api_key = settings.STRIPE_SECRET_KEY _stripe = _stripe_module logger.info("Stripe initialized") @@ -35,44 +41,73 @@ logger.warning("STRIPE_SECRET_KEY not set — billing endpoints disabled") -def _require_stripe(): +def _require_stripe() -> None: if not _stripe: - raise HTTPException( - status_code=503, - detail="Billing not configured. Set STRIPE_SECRET_KEY to enable.", - ) + raise HTTPException(status_code=503, detail="Billing not configured. Set STRIPE_SECRET_KEY.") + +def _tier_from_plan(plan: str) -> UserTier: + plan = (plan or "").lower() + if "enterprise" in plan: + return UserTier.ENTERPRISE + if "pro" in plan or "premium" in plan: + return UserTier.PRO + return UserTier.FREE -# ── Plans ──────────────────────────────────────────────────────────────────── PLANS: list[PlanInfo] = [ PlanInfo( - id="free", name="Free", tier=UserTier.FREE, - price_monthly=0, price_yearly=0, daily_limit=10, + id="free", + name="Free", + tier=UserTier.FREE, + price_monthly=0, + price_yearly=0, + daily_limit=10, features=["10 analyses/day", "Pattern detection", "Risk score", "API access"], ), PlanInfo( - id="pro_monthly", name="Pro", tier=UserTier.PRO, - price_monthly=19.90, price_yearly=14.90, daily_limit=100, + id="pro_monthly", + name="Pro", + tier=UserTier.PRO, + price_monthly=19.90, + price_yearly=14.90, + daily_limit=100, stripe_price_id_monthly=settings.STRIPE_PRICE_PRO_MONTHLY, stripe_price_id_yearly=settings.STRIPE_PRICE_PRO_YEARLY, - features=["100 analyses/day", "AI analysis (Claude)", "Link scanning", - "Social engineering detection", "Priority API", "CSV export", "Email support"], + features=[ + "100 analyses/day", + "AI analysis (Claude)", + "Link scanning", + "Social engineering detection", + "Priority API", + "CSV export", + "Email support", + ], ), PlanInfo( - id="enterprise", name="Enterprise", tier=UserTier.ENTERPRISE, - price_monthly=99.90, price_yearly=79.90, daily_limit=10000, + id="enterprise", + name="Enterprise", + tier=UserTier.ENTERPRISE, + price_monthly=99.90, + price_yearly=79.90, + daily_limit=10000, stripe_price_id_monthly=settings.STRIPE_PRICE_ENTERPRISE_MONTHLY, stripe_price_id_yearly=settings.STRIPE_PRICE_ENTERPRISE_YEARLY, - features=["10,000 analyses/day", "All Pro features", "Bulk API", - "Team management", "SLA 99.9%", "Dedicated support", "Custom integrations"], + features=[ + "10,000 analyses/day", + "All Pro features", + "Bulk API", + "Team management", + "SLA 99.9%", + "Dedicated support", + "Custom integrations", + ], ), ] @router.get("/plans", response_model=list[PlanInfo]) async def get_plans(): - """Get all available pricing plans""" return PLANS @@ -89,11 +124,10 @@ async def get_subscription(user=Depends(require_auth)): if _stripe and user_db.stripe_subscription_id: try: sub = _stripe.Subscription.retrieve(user_db.stripe_subscription_id) - from datetime import datetime current_period_end = datetime.fromtimestamp(sub["current_period_end"]) - cancel_at_period_end = sub["cancel_at_period_end"] + cancel_at_period_end = sub.get("cancel_at_period_end", False) except Exception as e: - logger.error(f"Stripe error: {e}") + logger.error(f"stripe_subscription_fetch_failed: {e}") return SubscriptionInfo( tier=user_db.tier, @@ -106,9 +140,7 @@ async def get_subscription(user=Depends(require_auth)): @router.post("/checkout", response_model=CheckoutResponse) async def create_checkout(request: CheckoutRequest, user=Depends(require_auth)): - """Create Stripe Checkout session for subscription upgrade""" _require_stripe() - db = UserRepository() user_db = await db.get_by_id(user.user_id) if not user_db: @@ -119,16 +151,23 @@ async def create_checkout(request: CheckoutRequest, user=Depends(require_auth)): "pro_yearly": settings.STRIPE_PRICE_PRO_YEARLY, "enterprise_monthly": settings.STRIPE_PRICE_ENTERPRISE_MONTHLY, "enterprise_yearly": settings.STRIPE_PRICE_ENTERPRISE_YEARLY, + "premium": settings.STRIPE_PRICE_PRO_MONTHLY, } price_id = price_map.get(request.plan) if not price_id: raise HTTPException(status_code=400, detail=f"Invalid plan. Options: {list(price_map.keys())}") + success_url = request.success_url or settings.STRIPE_SUCCESS_URL + cancel_url = request.cancel_url or settings.STRIPE_CANCEL_URL + if not success_url or not cancel_url: + raise HTTPException(status_code=400, detail="success_url and cancel_url are required") + try: customer_id = user_db.stripe_customer_id if not customer_id: customer = _stripe.Customer.create( - email=user_db.email, name=user_db.name, + email=user_db.email, + name=user_db.name, metadata={"trusthire_user_id": user_db.id}, ) customer_id = customer.id @@ -139,23 +178,28 @@ async def create_checkout(request: CheckoutRequest, user=Depends(require_auth)): payment_method_types=["card"], line_items=[{"price": price_id, "quantity": 1}], mode="subscription", - success_url=request.success_url + "?session_id={CHECKOUT_SESSION_ID}", - cancel_url=request.cancel_url, - subscription_data={"metadata": {"trusthire_user_id": user_db.id, "plan": request.plan}}, + success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url=cancel_url, allow_promotion_codes=True, + subscription_data={ + "metadata": { + "trusthire_user_id": user_db.id, + "plan": request.plan, + }, + "trial_period_days": max(0, settings.STRIPE_TRIAL_DAYS), + }, + metadata={"trusthire_user_id": user_db.id, "plan": request.plan}, ) - return CheckoutResponse(checkout_url=session.url, session_id=session.id) + return CheckoutResponse(checkout_url=session.url, session_id=session.id) except Exception as e: - logger.error(f"Stripe checkout error: {e}") + logger.error(f"stripe_checkout_failed: {e}") raise HTTPException(status_code=500, detail="Payment service error") @router.post("/portal", response_model=PortalResponse) async def create_portal(request: PortalRequest, user=Depends(require_auth)): - """Stripe Customer Portal — manage subscription, invoices, payment methods""" _require_stripe() - db = UserRepository() user_db = await db.get_by_id(user.user_id) if not user_db or not user_db.stripe_customer_id: @@ -168,49 +212,78 @@ async def create_portal(request: PortalRequest, user=Depends(require_auth)): ) return PortalResponse(portal_url=session.url) except Exception as e: - logger.error(f"Stripe portal error: {e}") + logger.error(f"stripe_portal_failed: {e}") raise HTTPException(status_code=500, detail="Payment service error") -@router.post("/webhook", include_in_schema=False) -async def stripe_webhook( - request: Request, - stripe_signature: str = Header(None, alias="stripe-signature"), -): - """Stripe webhook — syncs subscription state to user tier""" - _require_stripe() +async def process_stripe_event(event: dict) -> bool: + event_id = event.get("id") + if event_id and event_id in _processed_webhook_events: + logger.info("stripe_webhook_duplicate_ignored", extra={"event_id": event_id}) + return False - payload = await request.body() - try: - event = _stripe.Webhook.construct_event( - payload, stripe_signature, settings.STRIPE_WEBHOOK_SECRET - ) - except _stripe.error.SignatureVerificationError: - raise HTTPException(status_code=400, detail="Invalid webhook signature") + if event_id: + _processed_webhook_events.add(event_id) db = UserRepository() - event_type = event["type"] - data = event["data"]["object"] - logger.info("stripe_webhook", extra={"event_type": event_type}) + event_type = event.get("type", "") + data = event.get("data", {}).get("object", {}) + + logger.info("stripe_webhook_event", extra={"event_type": event_type}) if event_type == "checkout.session.completed": user_id = data.get("metadata", {}).get("trusthire_user_id") subscription_id = data.get("subscription") - plan = data.get("subscription_data", {}).get("metadata", {}).get("plan", "") + plan = data.get("metadata", {}).get("plan", "") if user_id and subscription_id: - tier = UserTier.PRO if "pro" in plan else (UserTier.ENTERPRISE if "enterprise" in plan else UserTier.FREE) - await db.update_subscription(user_id, tier, subscription_id, SubscriptionStatus.ACTIVE) + await db.update_subscription(user_id, _tier_from_plan(plan), subscription_id, SubscriptionStatus.ACTIVE) + + elif event_type == "customer.subscription.created": + metadata = data.get("metadata", {}) + user_id = metadata.get("trusthire_user_id") + sub_id = data.get("id") + if user_id and sub_id: + await db.update_subscription(user_id, _tier_from_plan(metadata.get("plan", "pro")), sub_id, SubscriptionStatus.ACTIVE) + + elif event_type in ("customer.subscription.deleted", "customer.subscription.updated"): + sub_id = data.get("id") + status = data.get("status", "") + customer = data.get("customer") + user = await db.get_by_stripe_customer(customer) if customer else None + if user: + if status in ("canceled", "unpaid", "incomplete_expired"): + await db.update_subscription(user.id, UserTier.FREE, None, SubscriptionStatus.CANCELED) + elif status in ("active", "trialing", "past_due"): + await db.update_subscription_status(user.id, SubscriptionStatus(status if status != "trialing" else "active")) + + elif event_type in ("invoice.payment_succeeded", "invoice.payment_failed"): + customer = data.get("customer") + user = await db.get_by_stripe_customer(customer) if customer else None + if user: + target = SubscriptionStatus.ACTIVE if event_type.endswith("succeeded") else SubscriptionStatus.PAST_DUE + await db.update_subscription_status(user.id, target) + + return True - elif event_type == "customer.subscription.deleted": - user_id = data.get("metadata", {}).get("trusthire_user_id") - if user_id: - await db.update_subscription(user_id, UserTier.FREE, None, SubscriptionStatus.CANCELED) - elif event_type == "invoice.payment_failed": - customer_id = data.get("customer") - if customer_id: - user = await db.get_by_stripe_customer(customer_id) - if user: - await db.update_subscription_status(user.id, SubscriptionStatus.PAST_DUE) +@router.post("/webhook", include_in_schema=False) +async def stripe_webhook(request: Request, stripe_signature: Optional[str] = Header(None, alias="stripe-signature")): + _require_stripe() + if not stripe_signature: + raise HTTPException(status_code=400, detail="Missing stripe-signature header") + + payload = await request.body() + + try: + event = _stripe.Webhook.construct_event(payload, stripe_signature, settings.STRIPE_WEBHOOK_SECRET) + except _stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid webhook signature") + except Exception as e: + logger.error(f"stripe_webhook_parse_failed: {e}") + raise HTTPException(status_code=400, detail="Invalid payload") + + processed = await process_stripe_event(event) + if not processed: + return JSONResponse(content={"status": "duplicate"}) - return JSONResponse(content={"received": True}) + return JSONResponse(content={"status": "success"}) diff --git a/api/resume.py b/api/resume.py new file mode 100644 index 0000000..94df981 --- /dev/null +++ b/api/resume.py @@ -0,0 +1,56 @@ +"""Resume optimization endpoints (ATS universal + PDF export).""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel, Field + +from ai.router import ai_router +from services.resume_optimizer import ResumeOptimizer, SUPPORTED_ATS, generate_resume_pdf + +router = APIRouter(prefix="/resume", tags=["Resume"]) +optimizer = ResumeOptimizer() + + +class ResumeOptimizeRequest(BaseModel): + resume_text: str = Field(..., min_length=20, max_length=25000) + job_description: str = Field(..., min_length=20, max_length=25000) + ats_provider: str = Field(default="generic") + output_format: str = Field(default="json") # json | pdf + + +@router.post("/optimize") +async def optimize_resume(request: ResumeOptimizeRequest): + provider = request.ats_provider.lower().strip() + if provider not in SUPPORTED_ATS: + provider = "generic" + + result = optimizer.optimize( + resume_text=request.resume_text, + job_description=request.job_description, + provider=provider, + ) + + ai_hint = ai_router("resume", request.job_description) + + if request.output_format == "pdf": + pdf_bytes = generate_resume_pdf(result.optimized_text) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=trusthire_resume_optimized.pdf"}, + ) + + if request.output_format != "json": + raise HTTPException(status_code=400, detail="output_format must be 'json' or 'pdf'") + + return { + "provider": result.provider, + "ats_score": result.score, + "missing_keywords": result.missing_keywords, + "suggestions": result.suggestions, + "optimized_text": result.optimized_text, + "ai": ai_hint, + "supported_ats": SUPPORTED_ATS, + } diff --git a/auth/auth_service.py b/auth/auth_service.py index e661136..d72b85b 100644 --- a/auth/auth_service.py +++ b/auth/auth_service.py @@ -22,7 +22,7 @@ # ==================== CRYPTO ==================== -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto") bearer_scheme = HTTPBearer(auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) diff --git a/config.py b/config.py index 510f138..d227b24 100644 --- a/config.py +++ b/config.py @@ -29,7 +29,7 @@ class Settings(BaseSettings): ALLOWED_HOSTS: str = "*" # CORS_ORIGINS as a plain string — split on comma at runtime # In Railway set as: https://felipeofdev-ai.github.io,https://trusthire.dev - CORS_ORIGINS_STR: str = "http://localhost:3000,http://localhost:8000,https://felipeofdev-ai.github.io,https://trusthire.dev" + CORS_ORIGINS_STR: str = "http://localhost:3000,http://localhost:5173,http://localhost:4173,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:5173,http://127.0.0.1:4173,http://127.0.0.1:8080,http://localhost:8000,https://felipeofdev-ai.github.io,https://trusthire.dev" @property def CORS_ORIGINS(self) -> list[str]: @@ -47,6 +47,9 @@ def CORS_ORIGINS(self) -> list[str]: AI_MAX_TOKENS: int = 500 AI_TIMEOUT: int = 15 AI_TEMPERATURE: float = 0.3 + OPENAI_API_KEY: Optional[str] = None + GPT_MODEL: str = "gpt-4.1" + CODEX_MODEL: str = "gpt-5-codex" # ==================== ANALYSIS ENGINE ==================== ENGINE_VERSION: str = "2.0.0" @@ -75,6 +78,10 @@ def CORS_ORIGINS(self) -> list[str]: LOG_LEVEL: str = "INFO" LOG_FORMAT: str = "json" METRICS_ENABLED: bool = True + ESTIMATED_COST_PER_REQUEST_USD: float = 0.002 + + # ==================== SECURITY ==================== + ENABLE_SECURITY_HEADERS: bool = True # ==================== STRIPE ==================== # Optional — billing disabled if empty @@ -85,6 +92,9 @@ def CORS_ORIGINS(self) -> list[str]: STRIPE_PRICE_PRO_YEARLY: str = "" STRIPE_PRICE_ENTERPRISE_MONTHLY: str = "" STRIPE_PRICE_ENTERPRISE_YEARLY: str = "" + STRIPE_SUCCESS_URL: str = "https://trusthire.ai/success" + STRIPE_CANCEL_URL: str = "https://trusthire.ai/cancel" + STRIPE_TRIAL_DAYS: int = 7 # ==================== FEATURES ==================== FEATURE_PDF_REPORTS: bool = True diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..a55587f --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + prometheus: + image: prom/prometheus:v2.54.1 + container_name: trusthire-prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + ports: + - "9090:9090" + restart: unless-stopped + + grafana: + image: grafana/grafana:11.2.0 + container_name: trusthire-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3001:3000" + depends_on: + - prometheus + - loki + restart: unless-stopped + + loki: + image: grafana/loki:3.1.1 + container_name: trusthire-loki + command: ["-config.file=/etc/loki/loki-config.yml"] + volumes: + - ./monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro + ports: + - "3100:3100" + restart: unless-stopped + + promtail: + image: grafana/promtail:3.1.1 + container_name: trusthire-promtail + command: ["-config.file=/etc/promtail/promtail-config.yml"] + volumes: + - ./monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro + - /var/log:/var/log:ro + depends_on: + - loki + restart: unless-stopped diff --git a/docs/ARCHITECTURE_DIAGRAM.md b/docs/ARCHITECTURE_DIAGRAM.md new file mode 100644 index 0000000..c1a6a62 --- /dev/null +++ b/docs/ARCHITECTURE_DIAGRAM.md @@ -0,0 +1,10 @@ +# Architecture Diagram (TrustHire) + + + +## Fluxo resumido +1. Usuário acessa frontend (Vercel/Edge). +2. Requisição vai para API (FastAPI) com autenticação. +3. Camada de análise + billing + observabilidade. +4. Dados operacionais em PostgreSQL + Redis. +5. Logs/métricas para Prometheus/Grafana/Loki. diff --git a/docs/ARQUITETURA.md b/docs/ARQUITETURA.md new file mode 100644 index 0000000..922c9f3 --- /dev/null +++ b/docs/ARQUITETURA.md @@ -0,0 +1,58 @@ +# Arquitetura Técnica TrustHire + +## Componentes +- **Frontend:** `index.html` (legado) + integração com `trusthire-frontend` (React/Vite/Next-ready). +- **Backend:** FastAPI com autenticação JWT, análise de risco e billing Stripe. +- **Dados:** PostgreSQL (relacional) + Redis (cache/rate limiting). +- **Observabilidade:** endpoint `/metrics`, logs JSON e healthcheck `/health`. + +## Fluxo de requisição +1. Cliente envia mensagem para `POST /api/v1/analyze`. +2. Middleware aplica headers de segurança e coleta latência/erro. +3. Auth valida JWT/API key. +4. Analyzer roda engine de padrões + IA (quando habilitada). +5. Resultado retorna com score e recomendações. +6. Evento de auditoria é registrado (user_id, ação, timestamp, IP, device). + +## Pipeline IA +- Entrada sanitizada no servidor. +- Pattern engine gera sinais determinísticos. +- Camada IA (Anthropic) complementa contexto (quando disponível). +- Risk scoring consolida score final (0-100). + +## Segurança básica implementada +- JWT + API key. +- Hash de senha com Argon2 (compatível com bcrypt legado). +- Rate limit com `slowapi`. +- Headers CSP, XSS, clickjacking, nosniff. +- Sanitização server-side de inputs textuais. +- Audit logs. + +## Infra recomendada +- Docker para build/deploy. +- Railway para runtime público. +- GitHub Actions para CI. +- Prometheus/Grafana para scraping de `/metrics`. + + +## Blueprint Tier-1 (sem alterar template visual) +- Edge/CDN/WAF: Cloudflare na frente do frontend (Vercel). +- API: FastAPI atrás de gateway + autoscaling container. +- Dados: PostgreSQL + Redis. +- Arquivos: object storage para documentos. +- Segurança: rate limit, headers hardened, validação server-side, audit logs. + +## IA Multi-Provider +- `Claude`: análise de risco/scam. +- `GPT`: otimização e reasoning de currículo. +- `Codex`: tarefas técnicas de parsing/code-assist. +- Roteamento centralizado em `ai/router.py`. + +## ATS Universal + PDF +- Endpoint: `POST /api/v1/resume/optimize` +- Providers suportados: Workday, Greenhouse, Lever, Taleo, iCIMS, SAP SuccessFactors, SmartRecruiters, BambooHR, Generic. +- Export: `output_format=pdf` retorna arquivo pronto para download. + +## Portfólio (recrutamento) +- Públicos recomendados: `frontend/`, `README.md`, `docs/ARQUITETURA.md`, `backend/main.py`, `routes/`, `services/`. +- Privados: `.env`, chaves, `infra/terraform`, pesos/modelos sensíveis. diff --git a/docs/BENCHMARK_PUBLIC.md b/docs/BENCHMARK_PUBLIC.md new file mode 100644 index 0000000..9748ccf --- /dev/null +++ b/docs/BENCHMARK_PUBLIC.md @@ -0,0 +1,5 @@ +# Benchmark Público + +Nenhum resultado de benchmark encontrado ainda. + +Execute: `bash scripts/run_load_test.sh http://localhost:8000` diff --git a/docs/CASE_STUDY_TECHNICAL.md b/docs/CASE_STUDY_TECHNICAL.md new file mode 100644 index 0000000..50299df --- /dev/null +++ b/docs/CASE_STUDY_TECHNICAL.md @@ -0,0 +1,31 @@ +# Case Study Técnico — TrustHire + +## Problema +Fraudes em recrutamento crescem em canais fora das plataformas oficiais. O objetivo foi construir uma API SaaS para análise de risco com foco em segurança, rastreabilidade e operação real. + +## Decisões de arquitetura +1. **FastAPI + camadas** + - Separação em `api/`, `services/`, `engine/`, `utils/`. +2. **Segurança primeiro** + - Headers hardening, sanitização server-side, autenticação JWT/API key, webhook Stripe assinado. +3. **Observabilidade nativa** + - Endpoints `/metrics`, `/metrics/prometheus`, `/system/health`. +4. **Billing de produção** + - Checkout/Portal Stripe + webhook com idempotência para evitar eventos duplicados. +5. **Evolução orientada a provas públicas** + - Stack de monitoring (Prometheus/Grafana/Loki), load testing com Locust e benchmark publicado. + +## Trade-offs +- **Idempotência de webhook** foi implementada in-memory para ambiente simples; para alta escala, migrar para Redis/PostgreSQL. +- **PDF fallback local** no frontend garante continuidade de UX, mas o caminho principal é backend para consistência e auditoria. + +## Resultados técnicos esperados +- Menor risco de inconsistência em billing por duplicidade de eventos Stripe. +- Aumento de confiabilidade operacional com monitoramento e métricas padronizadas. +- Melhor sinal técnico para recrutadores por documentação e benchmark reproduzível. + +## Próximos passos de elite +- OpenTelemetry tracing distribuído. +- Circuit breaker para provedores de IA. +- Cache distribuído e fila assíncrona para tarefas pesadas. +- Publicação contínua de benchmark em domínio público. diff --git a/docs/ROADMAP_FORTUNE_TIER1.md b/docs/ROADMAP_FORTUNE_TIER1.md new file mode 100644 index 0000000..cc194c3 --- /dev/null +++ b/docs/ROADMAP_FORTUNE_TIER1.md @@ -0,0 +1,71 @@ +# Roadmap Completo — TrustHire Tier‑1 Fortune + +## Objetivo +Transformar o TrustHire em SaaS sólido, auditável, escalável e atrativo para recrutamento técnico de alto nível, sem quebrar o template/UX atual. + +## Fase 1 — Estabilidade e Segurança (1–2 semanas) +- [x] Headers de segurança (CSP, nosniff, frame deny, etc.) +- [x] Rate limiting base +- [x] Audit logs +- [x] Endpoint de health e métricas +- [ ] Rate limit distribuído com Redis (por IP + token) +- [ ] JWT rotation (access curto + refresh seguro) +- [ ] Device fingerprint + comportamento anômalo por sessão + +### Critério de aceite +- 0 regressões funcionais +- ataque de burst 429 em <= 100 req/min por IP +- logs JSON em todos endpoints críticos + +## Fase 2 — IA e Resiliência (1–2 semanas) +- [x] Router multi-provider (Claude/GPT/Codex) +- [x] ATS universal + export PDF +- [ ] Circuit breaker para provedores IA +- [ ] Retry exponencial com jitter +- [ ] Feature flags para rollout gradual por rota/provedor + +### Critério de aceite +- fallback automático quando provider indisponível +- sem timeout cascata em picos + +## Fase 3 — Observabilidade Corporativa (1 semana) +- [ ] OpenTelemetry tracing (HTTP + serviços) +- [ ] Prometheus metrics avançadas +- [ ] Dashboard Grafana (latência, erro, rpm, custo) +- [ ] Alertas (SLO de p95 e error rate) + +### SLO inicial +- p95 < 250ms +- error_rate < 1% +- uptime > 99.9% + +## Fase 4 — Infra Tier‑1 (2–4 semanas) +- [ ] Cloudflare (WAF + Bot + CDN) +- [ ] Front em Vercel +- [ ] API em App Runner/ECS +- [ ] PostgreSQL gerenciado (RDS/Supabase) +- [ ] Redis gerenciado (ElastiCache/Upstash) +- [ ] Object storage para anexos +- [ ] IaC com Terraform + +## Fase 5 — Segurança Avançada e “AI Guardian” (1–2 semanas) +- [ ] Guardian Agent ativo (detecção de anomalia em logs) +- [ ] Auto-blocklist temporária por risco +- [ ] Regras de scraping e bot score +- [ ] Security playbooks (runbook de incidente) + +## Fase 6 — Recrutamento e Visibilidade GitHub (contínuo) +- [ ] README técnico com benchmark real +- [ ] ADRs (decisões de arquitetura) +- [ ] Diagramas de fluxo (req, auth, IA, storage) +- [ ] GitHub Project board: Backlog/Building/Testing/Prod +- [ ] Topics do repositório: ai, fastapi, distributed-systems, security, observability +- [ ] Releases com changelog semântico + +## Roadmap de execução recomendado (ordem) +1. Redis rate limit distribuído +2. Circuit breaker + retries IA +3. OpenTelemetry + Grafana +4. Cloudflare WAF/Bot + IaC +5. Guardian Agent com auto-ação controlada +6. Benchmarks públicos e portfolio polish diff --git a/docs/STRIPE_LIVE_SETUP.md b/docs/STRIPE_LIVE_SETUP.md new file mode 100644 index 0000000..558e236 --- /dev/null +++ b/docs/STRIPE_LIVE_SETUP.md @@ -0,0 +1,52 @@ +# Stripe Live Setup (TrustHire) + +## Segurança primeiro +Se uma chave live foi exposta em chat/commit/log, **rotacione imediatamente** no Stripe Dashboard. + +## 1) Configurar variáveis no Railway +- `STRIPE_SECRET_KEY` +- `STRIPE_PUBLISHABLE_KEY` +- `STRIPE_WEBHOOK_SECRET` +- `STRIPE_SUCCESS_URL` +- `STRIPE_CANCEL_URL` +- `STRIPE_TRIAL_DAYS` + +## 2) Criar produtos e preços via script +```bash +export STRIPE_SECRET_KEY=sk_live_... +bash scripts/stripe_setup_live.sh +``` + +O script retorna `STRIPE_PRICE_PRO_MONTHLY` e `STRIPE_PRICE_ENTERPRISE_MONTHLY`. + +## 3) Configurar webhook no Stripe +URL recomendada: +- `https://SEU_BACKEND/api/webhooks/stripe` + +Eventos obrigatórios: +- `checkout.session.completed` +- `invoice.payment_succeeded` +- `invoice.payment_failed` +- `customer.subscription.created` +- `customer.subscription.deleted` +- `payment_intent.succeeded` +- `payment_intent.payment_failed` + +## 4) Validar endpoint +```bash +curl -X POST https://SEU_BACKEND/api/webhooks/stripe +``` +Sem assinatura Stripe válida deve retornar `400` (ou `503` se Stripe não configurado). + + +## 5) Listener local (Stripe CLI) +```bash +bash scripts/stripe_webhook_local_test.sh http://localhost:8000 +``` + +Em outro terminal, simule eventos: +```bash +stripe trigger checkout.session.completed +stripe trigger invoice.payment_succeeded +stripe trigger customer.subscription.deleted +``` diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..e050a49 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,37 @@ + diff --git a/engine/pattern_engine.py b/engine/pattern_engine.py index 2db9a1d..dd9e35d 100644 --- a/engine/pattern_engine.py +++ b/engine/pattern_engine.py @@ -46,7 +46,7 @@ def _initialize_rules(self) -> List[PatternRule]: # ---------- FINANCIAL SIGNALS ---------- PatternRule( pattern=re.compile( - r"\b(pay|payment|transfer|send\s+money|wire|pix|paypal|venmo|cashapp|zelle)\b.*?\$?\d+", + r"(\b(pay|payment|transfer|send(\s+money)?|wire|pix|paypal|venmo|cashapp|zelle)\b[^\n]{0,40}\$?\d+)|(\$\d+[^\n]{0,40}\b(pay|payment|fee|paypal|transfer|wire|pix)\b)", re.I ), category=SignalCategory.FINANCIAL, @@ -84,7 +84,7 @@ def _initialize_rules(self) -> List[PatternRule]: re.I ), category=SignalCategory.URGENCY, - message="Creates artificial urgency", + message="Contains urgent pressure language", severity=Severity.MEDIUM, confidence=0.85, ), diff --git a/engine/risk_scoring.py b/engine/risk_scoring.py index 31dd35f..a7338dd 100644 --- a/engine/risk_scoring.py +++ b/engine/risk_scoring.py @@ -55,9 +55,9 @@ def calculate( # Combine scores with weights total_score = ( - signal_score * 0.60 + # Pattern signals: 60% - link_score * 0.25 + # Link analysis: 25% - se_score * 0.15 # Social engineering: 15% + signal_score * 0.80 + # Pattern signals: 80% + link_score * 0.10 + # Link analysis: 10% + se_score * 0.10 # Social engineering: 10% ) # Clamp to 0-100 @@ -79,8 +79,8 @@ def _score_signals(self, signals: List[Signal]) -> float: for s in signals ) - # Normalize to 0-100 scale (assuming max ~5 critical signals = 250 points) - normalized = min(weighted_sum / 2.5, 100) + # Normalize to 0-100 scale, emphasizing critical detections + normalized = min(weighted_sum / 1.2, 100) return normalized diff --git a/index.html b/index.html index 44cacb3..d74815f 100644 --- a/index.html +++ b/index.html @@ -218,7 +218,7 @@