Initial commit: CI Agent competitive-intelligence monitoring app

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).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
"""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()