FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
179 lines
7.1 KiB
Python
179 lines
7.1 KiB
Python
"""Centralized application configuration.
|
|
|
|
Every environment-dependent value is read here, once, via pydantic-settings.
|
|
Application code should depend on `get_settings()`, never on `os.environ`
|
|
directly - that's what keeps provider selection (LLM/search/notifications/auth)
|
|
swappable from a single place.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from typing import Literal
|
|
|
|
from pydantic import model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
|
|
# --- App ---
|
|
app_env: Literal["development", "test", "production"] = "development"
|
|
app_name: str = "CI Agent"
|
|
frontend_url: str = "http://localhost:3000"
|
|
backend_url: str = "http://localhost:8000"
|
|
|
|
# --- Reverse proxy ---
|
|
# Empty (default) = trust only the direct TCP connection for client-IP
|
|
# resolution (app.core.security.get_client_ip) - correct today, no proxy
|
|
# exists. Set to "CF-Connecting-IP" once deployed behind Cloudflare's
|
|
# proxy so IP-based localhost detection and the ban/throttle system read
|
|
# the real visitor IP instead of the proxy's own address. Only ever set
|
|
# this when it's actually known a trusted proxy sits in front and strips
|
|
# this header from untrusted clients - see KNOWN_LIMITATIONS.md.
|
|
trusted_proxy_ip_header: str = ""
|
|
|
|
# --- Local-dev convenience ---
|
|
# Comma-separated extra IPs that `is_localhost` treats as equivalent to
|
|
# real loopback, on top of 127.0.0.1/::1. Needed because Docker
|
|
# Desktop's bridge networking means even traffic that originates on the
|
|
# host machine itself (dev tooling driving a browser against
|
|
# localhost:3000/8000) arrives at the container from the bridge
|
|
# gateway address, not literal loopback - so without this,
|
|
# is_localhost is always false for that traffic, which both re-exposes
|
|
# the admin-only API-keys/logs boxes' loopback gate and forces the
|
|
# Turnstile widget to render (a real crash risk for automated browser
|
|
# tooling - see KNOWN_LIMITATIONS.md). Empty by default (no bypass);
|
|
# only ever populate this with IPs you know are your own dev host,
|
|
# never in a real deployment.
|
|
additional_trusted_local_ips: str = ""
|
|
|
|
# --- Auth ---
|
|
auth_mode: Literal["local", "jwt"] = "local"
|
|
jwt_secret: str = "dev-only-change-me-32-characters-minimum"
|
|
jwt_access_token_minutes: int = 15
|
|
jwt_refresh_token_days: int = 7
|
|
# Encrypts each user's own stored API keys at rest (app/core/crypto.py)
|
|
# - a Fernet key (44-char urlsafe-base64). This dev-only default is
|
|
# fixed/insecure by design (same precedent as jwt_secret above); a real
|
|
# deployment must set its own via `Fernet.generate_key()`. Rotating
|
|
# this value makes every already-stored user key undecryptable, so
|
|
# treat it like any other production secret - never regenerate it
|
|
# casually once real keys exist.
|
|
api_key_encryption_secret: str = "_wYtsm3nJ070987snBFp2eWVI5pyC0H9gGFUb6Cy4cQ="
|
|
|
|
# --- Database ---
|
|
database_url: str = "sqlite+aiosqlite:///./ciagent_dev.db"
|
|
|
|
# --- Redis / Celery ---
|
|
redis_url: str = "redis://localhost:6379/0"
|
|
celery_task_always_eager: bool = False
|
|
|
|
# --- LLM ---
|
|
llm_provider: Literal["mock", "anthropic", "ollama", "gemini"] = "mock"
|
|
anthropic_api_key: str = ""
|
|
anthropic_model: str = "claude-sonnet-5"
|
|
ollama_base_url: str = "http://localhost:11434"
|
|
ollama_model: str = "llama3.1"
|
|
# Gemini has an actual free rate-limited tier (unlike OpenAI's expiring
|
|
# trial credits), so it's the production option this app ships wired up.
|
|
gemini_api_key: str = ""
|
|
gemini_model: str = "gemini-2.0-flash"
|
|
llm_max_tokens_per_request: int = 4000
|
|
llm_max_retries: int = 2
|
|
|
|
# --- Search ---
|
|
search_provider: Literal["mock", "brave"] = "mock"
|
|
brave_search_api_key: str = ""
|
|
serpapi_api_key: str = ""
|
|
bing_search_api_key: str = ""
|
|
|
|
# --- Patents ---
|
|
# Free key via account registration at data.uspto.gov/apis/getting-started.
|
|
# Unset by default - PatentSourceCollector falls back to its existing
|
|
# honest disabled/fixture behavior when this is empty.
|
|
uspto_api_key: str = ""
|
|
|
|
# --- Company enrichment (NinjaPear / nubela.co) ---
|
|
# Paid, per-credit API - unset by default. Enrichment only ever fires
|
|
# once, at company-creation time (never on a recurring schedule), and
|
|
# the enqueue itself is skipped entirely when this is empty - see
|
|
# company_service.create_company.
|
|
ninjapear_api_key: str = ""
|
|
ninjapear_max_leadership_lookups: int = 5
|
|
|
|
# --- Email ---
|
|
smtp_host: str = "localhost"
|
|
smtp_port: int = 1025
|
|
smtp_username: str = ""
|
|
smtp_password: str = ""
|
|
smtp_from_email: str = "[email protected]"
|
|
smtp_use_tls: bool = False
|
|
|
|
# --- Resend (transactional security email: verification/reset/lockout) ---
|
|
# Unset by default - security_email_service falls back to the SMTP
|
|
# provider above (Mailpit locally) when this is empty, so the whole
|
|
# verification/reset flow is testable with zero Resend account needed.
|
|
# Deliberately separate from the alert-notification path (smtp_from_email
|
|
# above) - a different sender identity for account-security mail.
|
|
resend_api_key: str = ""
|
|
resend_security_from_email: str = "[email protected]"
|
|
|
|
# --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) ---
|
|
# Unset by default - skipped entirely for register/login/password-reset
|
|
# when either the caller is on loopback (see is_localhost) or no secret
|
|
# is configured, matching this app's usual optional-provider convention.
|
|
turnstile_site_key: str = ""
|
|
turnstile_secret: str = ""
|
|
|
|
# --- SMS ---
|
|
notification_sms_enabled: bool = False
|
|
sms_provider: Literal["twilio", "telnyx"] = "twilio"
|
|
twilio_account_sid: str = ""
|
|
twilio_auth_token: str = ""
|
|
twilio_from_number: str = ""
|
|
telnyx_api_key: str = ""
|
|
telnyx_from_number: str = ""
|
|
sms_monthly_cap: int = 50
|
|
|
|
# --- GitHub ---
|
|
github_token: str = ""
|
|
|
|
# --- Scheduling ---
|
|
default_timezone: str = "America/New_York"
|
|
default_monitoring_frequency: str = "weekly"
|
|
minimum_monitoring_interval_minutes: int = 60
|
|
|
|
# --- Scraper ---
|
|
scraper_user_agent: str = "CIAgentBot/1.0 (+https://ci-agent.local/bot)"
|
|
max_pages_per_domain: int = 25
|
|
scraper_request_timeout_seconds: int = 30
|
|
scraper_domain_delay_seconds: float = 2.0
|
|
|
|
# --- Cost controls ---
|
|
max_companies_per_user: int = 25
|
|
max_manual_runs_per_day: int = 10
|
|
|
|
# --- Retention / logging ---
|
|
data_retention_days: int = 365
|
|
log_level: str = "INFO"
|
|
|
|
@property
|
|
def is_production(self) -> bool:
|
|
return self.app_env == "production"
|
|
|
|
@model_validator(mode="after")
|
|
def _forbid_local_auth_in_production(self) -> Settings:
|
|
if self.app_env == "production" and self.auth_mode == "local":
|
|
raise ValueError(
|
|
"AUTH_MODE=local is a development convenience and must not be used "
|
|
"when APP_ENV=production. Set AUTH_MODE=jwt."
|
|
)
|
|
return self
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|