Files
replypier/backend/app/core/config.py

471 lines
25 KiB
Python

"""
Copyright 2024-2026 ReplyPier
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
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://replypier.site", "http://localhost:5173", "http://localhost:8000"]
class Settings(BaseSettings):
PROJECT_NAME: str = "ReplyPier"
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/replypier")
# 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@replypier.site")
FROM_NAME: str = os.getenv("FROM_NAME", "ReplyPier")
# 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", "replypier-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", "help.replypier.site")
# CNAME target customers point their custom help-center domain at.
HELP_CENTER_CNAME_TARGET: str = os.getenv("HELP_CENTER_CNAME_TARGET", "cname.replypier.site")
# 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 REPLYPIER 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}"
)