472 lines
25 KiB
Python
472 lines
25 KiB
Python
"""
|
|
Copyright 2024-2026 ChatterMate
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
from pydantic_settings import BaseSettings
|
|
from typing import List, Literal, NamedTuple, Optional
|
|
from dotenv import load_dotenv
|
|
from pathlib import Path
|
|
from pydantic import field_validator
|
|
from app.core.logger import get_logger
|
|
|
|
# Get the absolute path to the backend directory (parent of app directory)
|
|
BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
|
|
|
|
# ENV_FILE points the backend at a different env file. The e2e suite sets it to
|
|
# e2e/.env.e2e so nothing from a developer's backend/.env (dev DB, dev Redis,
|
|
# real provider keys) leaks into a test run.
|
|
ENV_FILE = Path(os.getenv("ENV_FILE") or BACKEND_DIR / ".env")
|
|
load_dotenv(ENV_FILE)
|
|
|
|
DEFAULT_CORS = ["https://chattermate.chat", "http://localhost:5173", "http://localhost:8000"]
|
|
|
|
class Settings(BaseSettings):
|
|
PROJECT_NAME: str = "ChatterMate"
|
|
VERSION: str = "0.1.0"
|
|
API_V1_STR: str = "/api/v1"
|
|
|
|
# Database
|
|
DATABASE_URL: str = os.getenv(
|
|
"DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/chattermate")
|
|
# Redis
|
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
REDIS_ENABLED: bool = os.getenv("REDIS_ENABLED", "false").lower() == "true"
|
|
|
|
# JWT
|
|
SECRET_KEY: str = os.getenv("JWT_SECRET_KEY", "your-secret-key")
|
|
ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
|
|
CONVERSATION_SECRET_KEY: str = os.getenv(
|
|
"CONVERSATION_SECRET_KEY", "your-conversation-secret-key")
|
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
|
|
# CORS Configuration
|
|
CORS_ORIGINS: List[str] = os.getenv("CORS_ORIGINS", DEFAULT_CORS)
|
|
|
|
|
|
# Firebase config
|
|
FIREBASE_CREDENTIALS: str = os.getenv(
|
|
"FIREBASE_CREDENTIALS", "app/config/firebase-config.json")
|
|
|
|
FRONTEND_URL: str = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
|
BACKEND_URL: str = os.getenv("BACKEND_URL", "http://localhost:8000")
|
|
# Where the widget bundle (widget.js / widget.css) is served from at /assets.
|
|
# The e2e suite points this at a bundle built from the current source, so the
|
|
# committed production build in backend/assets is never rebuilt locally.
|
|
WIDGET_ASSETS_DIR: str = os.getenv("WIDGET_ASSETS_DIR", str(BACKEND_DIR / "assets"))
|
|
VITE_WIDGET_URL: str = os.getenv("VITE_WIDGET_URL", "http://localhost:5173")
|
|
|
|
# app.core.encryption owns the actual key loading (reads the env var directly and
|
|
# refuses to start without it outside development). This mirror exists only so
|
|
# check_secret_configuration can audit it, hence no default: unset is itself
|
|
# one of the states that audit flags.
|
|
ENCRYPTION_KEY: str = os.getenv("ENCRYPTION_KEY", "")
|
|
# Downgrades the public-default ENCRYPTION_KEY startup error to a warning, for
|
|
# deployments whose existing data is encrypted under that key. See
|
|
# verify_secret_configuration.
|
|
ALLOW_INSECURE_ENCRYPTION_KEY: bool = os.getenv(
|
|
"ALLOW_INSECURE_ENCRYPTION_KEY", "false").lower() == "true"
|
|
|
|
# SMTP Settings
|
|
SMTP_SERVER: str = os.getenv("SMTP_SERVER", "smtp.gmail.com")
|
|
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
|
|
SMTP_USERNAME: str = os.getenv("SMTP_USERNAME", "your-email@gmail.com")
|
|
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "your-password")
|
|
FROM_EMAIL: str = os.getenv("FROM_EMAIL", "noreply@chattermate.chat")
|
|
FROM_NAME: str = os.getenv("FROM_NAME", "ChatterMate")
|
|
|
|
# Shopify
|
|
SHOPIFY_API_KEY: str = os.getenv("SHOPIFY_API_KEY", "")
|
|
SHOPIFY_API_SECRET: str = os.getenv("SHOPIFY_API_SECRET", "")
|
|
SHOPIFY_API_VERSION: str = os.getenv("SHOPIFY_API_VERSION", "2025-10")
|
|
|
|
# Slack. The OAuth redirect URI is derived from BACKEND_URL, not configured.
|
|
SLACK_CLIENT_ID: str = os.getenv("SLACK_CLIENT_ID", "")
|
|
SLACK_CLIENT_SECRET: str = os.getenv("SLACK_CLIENT_SECRET", "")
|
|
SLACK_SIGNING_SECRET: str = os.getenv("SLACK_SIGNING_SECRET", "")
|
|
|
|
# Jira. Unlike the others the redirect URI IS configured, because it must
|
|
# match the callback registered on the Atlassian app exactly. Self-hosters
|
|
# supply their own OAuth app creds.
|
|
JIRA_CLIENT_ID: str = os.getenv("JIRA_CLIENT_ID", "")
|
|
JIRA_CLIENT_SECRET: str = os.getenv("JIRA_CLIENT_SECRET", "")
|
|
JIRA_REDIRECT_URI: str = os.getenv("JIRA_REDIRECT_URI", "")
|
|
|
|
# CRM lead push (HubSpot / Pipedrive). OAuth redirect URIs are derived from
|
|
# BACKEND_URL, not configured. Self-hosters supply their own OAuth app creds.
|
|
HUBSPOT_CLIENT_ID: str = os.getenv("HUBSPOT_CLIENT_ID", "")
|
|
HUBSPOT_CLIENT_SECRET: str = os.getenv("HUBSPOT_CLIENT_SECRET", "")
|
|
PIPEDRIVE_CLIENT_ID: str = os.getenv("PIPEDRIVE_CLIENT_ID", "")
|
|
PIPEDRIVE_CLIENT_SECRET: str = os.getenv("PIPEDRIVE_CLIENT_SECRET", "")
|
|
|
|
# Meta (WhatsApp Cloud API, Messenger, Instagram) — one app, shared webhook.
|
|
# Self-hosters supply their own app; the cloud supplies its approved app.
|
|
META_APP_ID: str = os.getenv("META_APP_ID", "")
|
|
META_APP_SECRET: str = os.getenv("META_APP_SECRET", "")
|
|
# Our own random token echoed back during webhook GET verification
|
|
META_WEBHOOK_VERIFY_TOKEN: str = os.getenv("META_WEBHOOK_VERIFY_TOKEN", "")
|
|
META_GRAPH_VERSION: str = os.getenv("META_GRAPH_VERSION", "v21.0")
|
|
# WhatsApp Embedded Signup config id (cloud onboarding convenience)
|
|
META_CONFIG_ID: str = os.getenv("META_CONFIG_ID", "")
|
|
# Facebook Login for Business config id, for connecting a Page (Messenger and
|
|
# Instagram DM both ride on the Page's token). A separate configuration from
|
|
# META_CONFIG_ID: it requests pages_messaging + pages_show_list and returns a
|
|
# user token, not a WhatsApp signup.
|
|
META_MESSENGER_CONFIG_ID: str = os.getenv("META_MESSENGER_CONFIG_ID", "")
|
|
# Instagram API with Instagram Login: its own app id/secret, separate from
|
|
# the Facebook ones above. This flow needs no Facebook Page — the business
|
|
# signs in with Instagram and we get an Instagram user token.
|
|
INSTAGRAM_APP_ID: str = os.getenv("INSTAGRAM_APP_ID", "")
|
|
INSTAGRAM_APP_SECRET: str = os.getenv("INSTAGRAM_APP_SECRET", "")
|
|
# Comma-separated emails allowed to use one-click Meta signup. Empty means
|
|
# everyone, which is the end state — this exists so the flow can be exercised
|
|
# in production while the Meta app is still in App Review, since until it is
|
|
# approved the login only works for people with a role on the app anyway.
|
|
SIGNUP_ALLOWED_EMAILS: str = os.getenv("SIGNUP_ALLOWED_EMAILS", "")
|
|
|
|
VERIFY_SSL_CERTIFICATES: bool = os.getenv("VERIFY_SSL_CERTIFICATES", "true").lower() == "true"
|
|
# Which addresses the AI base URL, MCP server URLs and ticket databases may
|
|
# point at (see app/core/outbound_policy.py). "any" suits a self-hosted
|
|
# install reaching its own network; "public_only" refuses private,
|
|
# loopback, link-local and reserved targets on a shared deployment.
|
|
OUTBOUND_URL_POLICY: Literal["any", "public_only"] = os.getenv("OUTBOUND_URL_POLICY", "any")
|
|
# STDIO MCP connectors start a process on this server from a command an org
|
|
# admin types. Fine on your own install; on a shared deployment it hands
|
|
# every customer a shell, so turn it off there.
|
|
MCP_STDIO_ENABLED: bool = os.getenv("MCP_STDIO_ENABLED", "true").lower() == "true"
|
|
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
|
TRIAL_DAYS: int = 7 # 7-day trial period
|
|
|
|
# S3 Configuration
|
|
S3_FILE_STORAGE: bool = os.getenv("S3_FILE_STORAGE", "false").lower() == "true"
|
|
S3_BUCKET: str = os.getenv("S3_BUCKET", "chattermate-uploads")
|
|
S3_REGION: str = os.getenv("S3_REGION", "us-east-1")
|
|
# None, not "", when unset: boto3 only falls back to its default credential
|
|
# chain (env → shared config → IAM instance/container role) if these are
|
|
# None. An empty string is treated as an explicit credential, so requests
|
|
# get signed with a blank access key and fail with InvalidAccessKeyId —
|
|
# which is what blocks EC2/ECS/EKS deployments from using an attached role.
|
|
AWS_ACCESS_KEY_ID: Optional[str] = os.getenv("AWS_ACCESS_KEY_ID") or None
|
|
AWS_SECRET_ACCESS_KEY: Optional[str] = os.getenv("AWS_SECRET_ACCESS_KEY") or None
|
|
# Presigned URLs are regenerated on every response, so this only needs to
|
|
# outlive a single page render. Hard-capped at S3_MAX_PRESIGN_SECONDS.
|
|
S3_PRESIGN_EXPIRY_SECONDS: int = int(os.getenv("S3_PRESIGN_EXPIRY_SECONDS", "3600"))
|
|
|
|
# Enhanced Website Knowledge Base Configuration
|
|
KB_MAX_DEPTH: int = int(os.getenv("KB_MAX_DEPTH", "5"))
|
|
KB_MAX_LINKS: int = int(os.getenv("KB_MAX_LINKS", "25"))
|
|
KB_MIN_CONTENT_LENGTH: int = int(os.getenv("KB_MIN_CONTENT_LENGTH", "100"))
|
|
KB_TIMEOUT: int = int(os.getenv("KB_TIMEOUT", "30"))
|
|
KB_MAX_RETRIES: int = int(os.getenv("KB_MAX_RETRIES", "3"))
|
|
KB_MAX_WORKERS: int = int(os.getenv("KB_MAX_WORKERS", "5"))
|
|
KB_BATCH_SIZE: int = int(os.getenv("KB_BATCH_SIZE", "5"))
|
|
# A website source that has not finished reading in this long fails rather
|
|
# than running on (one hit the worker's memory limit after three hours).
|
|
KB_SOURCE_TIME_LIMIT_SECONDS: int = int(os.getenv("KB_SOURCE_TIME_LIMIT_SECONDS", "1800"))
|
|
# Pages a PDF may have (app/knowledge/pdf_limits.py): every page is
|
|
# embedded, and a scanned one OCR'd, so bytes alone don't bound the work.
|
|
KB_PDF_MAX_PAGES: int = int(os.getenv("KB_PDF_MAX_PAGES", "300"))
|
|
|
|
# Hard ceiling on a single agent run (model + tool calls). A stuck run is
|
|
# cancelled instead of hanging the chat handler forever (issue #269).
|
|
AGENT_RUN_TIMEOUT: int = int(os.getenv("AGENT_RUN_TIMEOUT", "90"))
|
|
|
|
# agno's own run reporting to api.agno.com (see app.core.agno_telemetry).
|
|
# Off unless an operator opts in.
|
|
AGNO_TELEMETRY: bool = os.getenv("AGNO_TELEMETRY", "false").lower() == "true"
|
|
AGNO_MONITOR: bool = os.getenv("AGNO_MONITOR", "false").lower() == "true"
|
|
|
|
# Tool calls allowed in a single turn before the agent must answer from what
|
|
# it already has. Reaching this is no longer fatal — the agent answers without
|
|
# further tools rather than returning nothing (see app.utils.agno_patches) —
|
|
# so the budget can stay tight. AGENT_RUN_TIMEOUT is the real safety net.
|
|
AGENT_TOOL_CALL_LIMIT: int = int(os.getenv("AGENT_TOOL_CALL_LIMIT", "5"))
|
|
|
|
# Chars of previous-turn history re-sent to the model each turn. Tool
|
|
# results from earlier turns are always stubbed out (see
|
|
# app.agents.history_memory); this bounds what remains by dropping the oldest
|
|
# turns. Stored history is never touched.
|
|
AGENT_HISTORY_MAX_CHARS: int = int(os.getenv("AGENT_HISTORY_MAX_CHARS", "40000"))
|
|
|
|
# Knowledge chunking. The search embedder (bge-small, 512-token window) only
|
|
# sees roughly this much text per row, so a bigger chunk is not searchable
|
|
# past this point — and crawled text is URL-dense (~3 chars/token).
|
|
KNOWLEDGE_CHUNK_SIZE: int = int(os.getenv("KNOWLEDGE_CHUNK_SIZE", "1100"))
|
|
KNOWLEDGE_CHUNK_OVERLAP: int = int(os.getenv("KNOWLEDGE_CHUNK_OVERLAP", "120"))
|
|
# Hits returned per knowledge search, and the ceiling on the rendered result
|
|
# (whole pages are dropped past it, a page is never cut mid-chunk).
|
|
KNOWLEDGE_SEARCH_RESULTS: int = int(os.getenv("KNOWLEDGE_SEARCH_RESULTS", "6"))
|
|
KNOWLEDGE_SEARCH_MAX_CHARS: int = int(os.getenv("KNOWLEDGE_SEARCH_MAX_CHARS", "15000"))
|
|
|
|
# Knowledge base content summarization settings
|
|
KNOWLEDGE_SUMMARY_ENABLED: bool = os.getenv("KNOWLEDGE_SUMMARY_ENABLED", "false").lower() == "true"
|
|
KNOWLEDGE_SUMMARY_MODEL_TYPE: str = os.getenv("KNOWLEDGE_SUMMARY_MODEL_TYPE", "GROQ")
|
|
KNOWLEDGE_SUMMARY_MODEL_NAME: str = os.getenv("KNOWLEDGE_SUMMARY_MODEL_NAME", "llama-3.1-8b-instant")
|
|
KNOWLEDGE_SUMMARY_API_KEY: str = os.getenv("KNOWLEDGE_SUMMARY_API_KEY", "")
|
|
KNOWLEDGE_SUMMARY_MAX_TOKENS: int = int(os.getenv("KNOWLEDGE_SUMMARY_MAX_TOKENS", "4000"))
|
|
# Required when KNOWLEDGE_SUMMARY_MODEL_TYPE=OPENAI_COMPATIBLE, ignored otherwise.
|
|
KNOWLEDGE_SUMMARY_BASE_URL: str = os.getenv("KNOWLEDGE_SUMMARY_BASE_URL", "")
|
|
|
|
# Global chat guardrails (platform-owned; deliberately NOT tenant-configurable).
|
|
# GUARDRAIL_POLICY_ENABLED -> prepend the code-owned policy block to every
|
|
# chat agent system prompt.
|
|
# GUARDRAIL_INBOUND_ACTION -> pre-LLM injection check on visitor messages:
|
|
# "off" (never block), "template_only" (block
|
|
# only literal chat-template tokens a human
|
|
# never types), "strict" (block every strong
|
|
# injection signal). Non-blocking hits are
|
|
# always still counted.
|
|
# GUARDRAIL_OUTPUT_CHECK_ENABLED -> scan replies for leaked policy text and
|
|
# count scope refusals.
|
|
# GUARDRAIL_EVENTS_ENABLED -> persist trigger events to guardrail_events.
|
|
# GUARDRAIL_STORE_EXCERPT -> store an encrypted 300-char excerpt of the
|
|
# offending text on each event for review.
|
|
GUARDRAIL_POLICY_ENABLED: bool = os.getenv("GUARDRAIL_POLICY_ENABLED", "true").lower() == "true"
|
|
GUARDRAIL_INBOUND_ACTION: str = os.getenv("GUARDRAIL_INBOUND_ACTION", "template_only")
|
|
# Long self-contained exercise briefs with no business context. Defaults to
|
|
# "block": prompt text demonstrably failed to hold these in production, and
|
|
# blocking here also avoids paying for the inference. "count" or "off" to
|
|
# relax without a deploy.
|
|
GUARDRAIL_OFFTOPIC_ACTION: str = os.getenv("GUARDRAIL_OFFTOPIC_ACTION", "block")
|
|
GUARDRAIL_OUTPUT_CHECK_ENABLED: bool = os.getenv("GUARDRAIL_OUTPUT_CHECK_ENABLED", "true").lower() == "true"
|
|
GUARDRAIL_EVENTS_ENABLED: bool = os.getenv("GUARDRAIL_EVENTS_ENABLED", "true").lower() == "true"
|
|
GUARDRAIL_STORE_EXCERPT: bool = os.getenv("GUARDRAIL_STORE_EXCERPT", "true").lower() == "true"
|
|
|
|
# Help center (public FAQ site)
|
|
# How the public help center URL is advertised (live_url):
|
|
# "path" -> {BACKEND_URL}/help/{slug}, served same-origin as the API.
|
|
# Works on localhost/self-host with no DNS/TLS/proxy. Default.
|
|
# "subdomain" -> https://{slug}.<HELP_CENTER_BASE_DOMAIN> (cloud). MUST be set
|
|
# on cloud so subdomain help centers keep their branded URL.
|
|
# A verified custom domain always takes precedence over both. Host-based dispatch
|
|
# (subdomains + custom domains) stays active regardless; only path dispatch is gated
|
|
# to "path" mode.
|
|
HELP_CENTER_PUBLIC_MODE: str = os.getenv("HELP_CENTER_PUBLIC_MODE", "path")
|
|
# Base domain serving {slug}.<base> help centers.
|
|
HELP_CENTER_BASE_DOMAIN: str = os.getenv("HELP_CENTER_BASE_DOMAIN", "chattermate.help")
|
|
# CNAME target customers point their custom help-center domain at.
|
|
HELP_CENTER_CNAME_TARGET: str = os.getenv("HELP_CENTER_CNAME_TARGET", "cname.chattermate.chat")
|
|
# IPs the CNAME target resolves to — accepted when a provider flattens the
|
|
# CNAME into A/AAAA records (comma-separated).
|
|
HELP_CENTER_TARGET_IPS: frozenset = frozenset(
|
|
ip.strip() for ip in os.getenv("HELP_CENTER_TARGET_IPS", "").split(",") if ip.strip()
|
|
)
|
|
# FAQ generation cost caps (per source / per LLM call) and import fetch limits.
|
|
FAQ_MAX_PAGES_PER_SOURCE: int = int(os.getenv("FAQ_MAX_PAGES_PER_SOURCE", "300"))
|
|
FAQ_MAX_BATCH_CHARS: int = int(os.getenv("FAQ_MAX_BATCH_CHARS", "15000"))
|
|
# Ceiling for context-window-derived batch sizing (see utils/model_context.py)
|
|
# — a quality guard for very-large-context models, not a token limit.
|
|
FAQ_MAX_BATCH_CHARS_CEILING: int = int(os.getenv("FAQ_MAX_BATCH_CHARS_CEILING", "60000"))
|
|
# Force a context-window size (tokens) for exotic/self-hosted models; 0 = auto.
|
|
FAQ_CONTEXT_TOKENS_OVERRIDE: int = int(os.getenv("FAQ_CONTEXT_TOKENS_OVERRIDE", "0"))
|
|
# Meter FAQ generation credits even for orgs on their own API key
|
|
# (default: hosted CHATTERMATE model only).
|
|
FAQ_METER_OWN_KEY: bool = os.getenv("FAQ_METER_OWN_KEY", "false").lower() == "true"
|
|
FAQ_IMPORT_MAX_PAGE_CHARS: int = int(os.getenv("FAQ_IMPORT_MAX_PAGE_CHARS", "100000"))
|
|
FAQ_IMPORT_FETCH_TIMEOUT: int = int(os.getenv("FAQ_IMPORT_FETCH_TIMEOUT", "30"))
|
|
# Article-mode import (crawl linked pages, no LLM): crawl and re-host caps.
|
|
# High enough to pull a whole mid-size help center in one pass; a deliberate
|
|
# one-time migration in a background worker, so slowness is acceptable.
|
|
FAQ_ARTICLE_IMPORT_MAX_PAGES: int = int(os.getenv("FAQ_ARTICLE_IMPORT_MAX_PAGES", "200"))
|
|
FAQ_ARTICLE_IMPORT_MAX_IMAGES: int = int(os.getenv("FAQ_ARTICLE_IMPORT_MAX_IMAGES", "10"))
|
|
# Category/section listing pages to follow for the full per-category article
|
|
# list (help-center homepages truncate each section to a few articles).
|
|
FAQ_ARTICLE_IMPORT_MAX_CATEGORIES: int = int(os.getenv("FAQ_ARTICLE_IMPORT_MAX_CATEGORIES", "20"))
|
|
# A 'processing' FAQ job whose progress hasn't advanced in this long is
|
|
# treated as dead (worker crashed/killed): excluded from active-job polling
|
|
# and reaped on the next enqueue. Generous — must exceed the slowest single
|
|
# LLM batch / page fetch so a live-but-slow job is never killed.
|
|
FAQ_JOB_STALE_SECONDS: int = int(os.getenv("FAQ_JOB_STALE_SECONDS", "600"))
|
|
# FAQ generation/import jobs processed concurrently across ALL orgs. Default
|
|
# 1 = strictly one business at a time (each job does LLM calls + vector-DB
|
|
# reads); raise for more throughput on a bigger host.
|
|
MAX_CONCURRENT_FAQ_JOBS: int = int(os.getenv("MAX_CONCURRENT_FAQ_JOBS", "1"))
|
|
# AI ticket triage/investigation runs processed concurrently across ALL
|
|
# orgs (each run does LLM calls; investigations later add MCP subprocesses).
|
|
MAX_CONCURRENT_INVESTIGATIONS: int = int(os.getenv("MAX_CONCURRENT_INVESTIGATIONS", "2"))
|
|
# Subdomain labels reserved for infrastructure — must mirror the DNS/nginx
|
|
# records that exist on the base domain, hence env-configurable.
|
|
HELP_CENTER_RESERVED_SLUGS: frozenset = frozenset(
|
|
s.strip() for s in os.getenv(
|
|
"HELP_CENTER_RESERVED_SLUGS",
|
|
"www,api,app,help,mail,admin,staging,cname,status",
|
|
).split(",") if s.strip()
|
|
)
|
|
|
|
# Embedding Model Configuration
|
|
EMBEDDING_BATCH_SIZE: int = int(os.getenv("EMBEDDING_BATCH_SIZE", "32"))
|
|
EMBEDDING_MAX_WORKERS: int = int(os.getenv("EMBEDDING_MAX_WORKERS", "4"))
|
|
|
|
# FastEmbed Configuration
|
|
FASTEMBED_MODEL: str = os.getenv("FASTEMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
|
|
|
# Embedding Optimization Configuration
|
|
ENABLE_IMMEDIATE_EMBEDDING: bool = os.getenv("ENABLE_IMMEDIATE_EMBEDDING", "true").lower() == "true"
|
|
|
|
# Embedding Safety Configuration (for Docker environments)
|
|
EMBEDDING_SINGLE_THREADED: bool = os.getenv("EMBEDDING_SINGLE_THREADED", "true").lower() == "true"
|
|
EMBEDDING_SEQUENTIAL_FALLBACK: bool = os.getenv("EMBEDDING_SEQUENTIAL_FALLBACK", "true").lower() == "true"
|
|
|
|
# Explore View Configuration
|
|
EXPLORE_SOURCE_ORG_ID: str = os.getenv("EXPLORE_SOURCE_ORG_ID", "bab82aab-d095-46f8-bf16-da638671bcf4")
|
|
EXPLORE_AGENT_ID: str = os.getenv("EXPLORE_AGENT_ID", "b20188ee-2800-41d0-8bf1-8fc291ab0076")
|
|
EXPLORE_USER_ID: str = os.getenv("EXPLORE_USER_ID", "154540a3-6177-4b1b-aab2-f23f0ef74ac7")
|
|
EXPLORE_WIDGET_ID: str = os.getenv("EXPLORE_WIDGET_ID", "397046dc-0093-4499-ab45-a0afe3c3ee14")
|
|
|
|
@field_validator("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", mode="after")
|
|
@classmethod
|
|
def _blank_credential_is_unset(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Coerce a blank credential to None so boto3 uses its default chain.
|
|
|
|
The field default already does this, but BaseSettings reads the
|
|
environment itself and overrides it — a var that is present but empty
|
|
(`AWS_ACCESS_KEY_ID=` in .env, or an env_file entry with no value)
|
|
lands as "" and would be signed with as an explicit blank credential.
|
|
"""
|
|
return v or None
|
|
|
|
model_config = {
|
|
"case_sensitive": True,
|
|
"env_file": ENV_FILE,
|
|
"extra": "allow", # This allows extra fields from .env
|
|
}
|
|
|
|
settings = Settings()
|
|
|
|
|
|
def webhooks_base_url() -> str:
|
|
"""Where platforms reach this backend's inbound webhooks."""
|
|
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks"
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# Environments where the public defaults are acceptable, because nothing durable or
|
|
# reachable is protected by them. Anything else is treated as a real deployment.
|
|
_THROWAWAY_ENVIRONMENTS = frozenset({"development", "test", "testing"})
|
|
|
|
# Shell one-liners producing a real value for each kind of secret, quoted back in
|
|
# the startup error so the fix does not require going and finding the docs.
|
|
# ENCRYPTION_KEY_HINT is public because app.core.encryption reports the same fix
|
|
# when the key is missing at load time, and the two must not drift apart.
|
|
ENCRYPTION_KEY_HINT = (
|
|
'python -c "import base64;from cryptography.fernet import Fernet;'
|
|
'print(base64.b64encode(Fernet.generate_key()).decode())"'
|
|
)
|
|
_HEX_SECRET_HINT = "openssl rand -hex 32"
|
|
|
|
|
|
class _ManagedSecret(NamedTuple):
|
|
"""A secret the app refuses to serve traffic without.
|
|
|
|
One record per secret rather than parallel dicts keyed by setting name: adding
|
|
a secret should be one line, and a half-filled entry should not surface as a
|
|
KeyError while building the very error meant to explain the problem.
|
|
"""
|
|
|
|
# What the operator sets, which is not always the setting name.
|
|
env_var: str
|
|
# Value once shipped in this repo (config default / .env.example). Public, so
|
|
# it stays listed after being removed as a default — deployments copied it.
|
|
public_default: str
|
|
# Shell one-liner producing a real replacement.
|
|
generate: str
|
|
|
|
|
|
_MANAGED_SECRETS = {
|
|
"SECRET_KEY": _ManagedSecret(
|
|
"JWT_SECRET_KEY", "your-secret-key", _HEX_SECRET_HINT),
|
|
"CONVERSATION_SECRET_KEY": _ManagedSecret(
|
|
"CONVERSATION_SECRET_KEY", "your-conversation-secret-key", _HEX_SECRET_HINT),
|
|
"ENCRYPTION_KEY": _ManagedSecret(
|
|
"ENCRYPTION_KEY",
|
|
"RFQ4SzhyRTVYdGtsLUxsc25SaDB0QlZpbTdQRmlVRlpsZUlCaFRlU2Vxbz0=",
|
|
ENCRYPTION_KEY_HINT),
|
|
}
|
|
|
|
# .env.example placeholders - not secret either, and they mean "never configured"
|
|
_PLACEHOLDER_VALUES = {
|
|
"your_jwt_secret_key_here",
|
|
"your_conversation_secret_key_here",
|
|
"your_fernet_encryption_key_here",
|
|
}
|
|
|
|
|
|
def is_throwaway_environment(config: Settings = settings) -> bool:
|
|
"""True for local development and test runs, where the public defaults are
|
|
harmless. Defined here so every check of "is this a real deployment" agrees;
|
|
app.core.encryption asks the same question before generating a throwaway key.
|
|
"""
|
|
return config.ENVIRONMENT.lower() in _THROWAWAY_ENVIRONMENTS
|
|
|
|
|
|
def check_secret_configuration(config: Settings = settings) -> list[str]:
|
|
"""Names of the auth/encryption secrets that are missing or still at a public
|
|
default/placeholder. Empty in development, which runs on the defaults on purpose.
|
|
|
|
Pure audit: verify_secret_configuration decides what to do about the result.
|
|
"""
|
|
if is_throwaway_environment(config):
|
|
return []
|
|
|
|
return [
|
|
name for name, secret in _MANAGED_SECRETS.items()
|
|
if not getattr(config, name, None)
|
|
or getattr(config, name) == secret.public_default
|
|
or getattr(config, name) in _PLACEHOLDER_VALUES
|
|
]
|
|
|
|
|
|
def verify_secret_configuration(config: Settings = settings) -> None:
|
|
"""Refuse to start on secrets anyone can look up in this repo.
|
|
|
|
Every value in _MANAGED_SECRETS and _PLACEHOLDER_VALUES is public, so a
|
|
deployment using one has no auth boundary at all — tokens can be forged and
|
|
stored credentials decrypted by anyone. A warning was not enough: it scrolls
|
|
past in the boot log and the deployment stays exposed indefinitely.
|
|
|
|
ENCRYPTION_KEY alone gets an escape hatch. An instance predating the required
|
|
key has real data encrypted under the public one, and refusing to boot would
|
|
lock it out of that data — worse than the exposure it already lives with.
|
|
Rotating a JWT secret only ends sessions, so those get no exemption.
|
|
"""
|
|
insecure = check_secret_configuration(config)
|
|
|
|
if "ENCRYPTION_KEY" in insecure and config.ALLOW_INSECURE_ENCRYPTION_KEY:
|
|
logger.warning(
|
|
"ENCRYPTION_KEY is the public default and ALLOW_INSECURE_ENCRYPTION_KEY "
|
|
"is set, so startup continues. Conversations and stored credentials in "
|
|
"this database can be decrypted by anyone who has a copy of it. Generate "
|
|
"a real key (%s), re-encrypt what the old key wrote, then unset this.",
|
|
ENCRYPTION_KEY_HINT,
|
|
)
|
|
insecure = [name for name in insecure if name != "ENCRYPTION_KEY"]
|
|
|
|
if not insecure:
|
|
return
|
|
|
|
names = ", ".join(insecure)
|
|
verb = "is" if len(insecure) == 1 else "are"
|
|
fixes = "\n".join(
|
|
f" {_MANAGED_SECRETS[name].env_var}=$({_MANAGED_SECRETS[name].generate})"
|
|
for name in insecure
|
|
)
|
|
raise RuntimeError(
|
|
f"Refusing to start: {names} {verb} unset or still set to a value published "
|
|
"in this repository, so anyone can forge tokens or decrypt stored data. "
|
|
f"Set real values and restart:\n{fixes}"
|
|
)
|