from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict # The visible product name is fixed and never translated or configured per-deployment -- # see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers # (package name, Compose project, database name, repository) intentionally remain # "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title). PRODUCT_NAME = "Fleet Ops" class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") mobilityops_env: str = "development" mobilityops_demo_mode: bool = True database_url: str = "postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops" knowledge_provider: str = "demo" ragcore_base_url: str = "http://ragcore-api:8000" ragcore_tenant: str = "northstar-mobility-demo" ragcore_workspace: str = "mobilityops" ragcore_collection: str = "internal-procedures" ragcore_api_token: str = "" ragcore_space_id: str = "" ragcore_http_timeout_seconds: float = 5.0 # Search fallback is only labelled grounded above this explicit retrieval threshold. ragcore_min_search_score: float = 0.05 n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token" n8n_callback_token: str = "replace-me-n8n-callback-token" n8n_dispatch_enabled: bool = True n8n_dispatch_interval_seconds: float = 3.0 n8n_http_timeout_seconds: float = 5.0 n8n_max_attempts: int = 5 n8n_delivery_lease_seconds: float = 120.0 app_secret: str = "replace-in-production" session_cookie_name: str = "mobilityops_session" session_ttl_seconds: int = 60 * 60 * 8 session_cookie_secure: bool = False seed_dir: str = "/app/seed" knowledge_dir: str = "/app/knowledge/procedures" mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False # MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles # its catalog into the gateway; Fleet Ops never pushes a registration call), so # these are only used for an honest reachability health check, not self-registration. mcp_hub_base_url: str = "" mcp_provider_id: str = "fleet-ops" cors_allow_origins: str = "http://localhost:1228" demo_organization_name: str = "Northstar Mobility" demo_timezone: str = "Europe/Brussels" demo_allow_reset: bool = True demo_reset_cooldown_seconds: int = 60 mcp_hub_health_cache_seconds: int = 60 initial_admin_email: str = "" initial_admin_password: str = "" initial_admin_display_name: str = "Operations Manager" mobilityops_public_url: str = "http://localhost:1228" oidc_enabled: bool = False oidc_provider_name: str = "Organisatieaccount" oidc_issuer_url: str = "" oidc_client_id: str = "" oidc_client_secret: str = "" oidc_redirect_uri: str = "" oidc_allowed_email_domains: str = "" oidc_auto_provision: bool = True oidc_default_role: str = "rental_employee" log_level: str = "INFO" # Failed password logins per client IP before a temporary 429 (0 disables). login_max_failures: int = 10 login_failure_window_seconds: int = 900 knowledge_max_requests: int = 30 knowledge_rate_limit_window_seconds: int = 60 metrics_bearer_token: str = "" privacy_minimum_booking_retention_days: int = 30 privacy_audit_retention_days: int = 2555 privacy_audit_export_max_rows: int = 10000 # Secrets that guard *inbound* trust (session cookies, service callbacks). Running # production with any of these at their placeholder value means forged sessions or # unauthenticated writes, so startup refuses. INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = ( ("app_secret", "replace-in-production"), ("n8n_callback_token", "replace-me-n8n-callback-token"), ("mcp_hub_service_token", "replace-me-mcp-hub-token"), ) def insecure_default_secrets(settings: "Settings") -> list[str]: """Return the names of secret settings that still carry their placeholder value. MCP routes are always mounted, independently of the Hub reachability-status flag, so their inbound token must always be non-placeholder in production. """ insecure: list[str] = [] for name, placeholder in INSECURE_DEFAULT_SECRETS: value = getattr(settings, name) if not value or value == placeholder or value.startswith("replace-me"): insecure.append(name) return insecure @lru_cache def get_settings() -> Settings: settings = Settings() if settings.mobilityops_env.lower() == "production": insecure = insecure_default_secrets(settings) if insecure: # Refuse to boot rather than run production with forgeable session cookies # or guessable service tokens. Development/test/demo keep the defaults. raise RuntimeError( "Refusing to start in production with placeholder secrets: " + ", ".join(insecure) + ". Set real values in the environment (see .env.example)." ) if not settings.mobilityops_public_url.lower().startswith("https://"): raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.") if not settings.session_cookie_secure: raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.") return settings