""" Muse Configuration Environment-based configuration for the Muse service. """ import logging from functools import lru_cache from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict def _app_version_from_package() -> str: """Read version from the single source of truth (pyproject.toml via protocol.version).""" from musehub.protocol.version import MUSE_VERSION return MUSE_VERSION class Settings(BaseSettings): """Application settings loaded from environment variables.""" # Service Info app_name: str = "Muse" app_version: str = _app_version_from_package() debug: bool = False muse_env: str = "production" # "test" | "development" | "production" # Public base URL — used to build clone URLs returned by the API and MCP tools. # Override via PUBLIC_URL env var on staging/local: e.g. https://staging.musehub.ai public_url: str = "https://musehub.ai" # Allowlist of Host header values that the server will trust when building # request-derived URLs (e.g. clone URLs on the repo home page). # Any Host value not in this set falls back to public_url. # Override via ALLOWED_HOSTS env var (JSON array): # ALLOWED_HOSTS='["musehub.ai","staging.musehub.ai","localhost:1337"]' allowed_hosts: list[str] = ["musehub.ai", "staging.musehub.ai", "localhost:1337"] # Server Configuration host: str = "0.0.0.0" port: int = 10001 # Database Configuration — PostgreSQL only # Example: postgresql+asyncpg://user:pass@localhost:5432/musehub database_url: str | None = None db_password: str | None = None # CORS Settings (fail closed: no default origins) # Set CORS_ORIGINS (JSON array) in .env. Local dev: ["https://localhost:1337", "muse://"]. # Production: exact origins only. Never use "*" in production. cors_origins: list[str] = [] @model_validator(mode="after") def _warn_cors_wildcard_in_production(self) -> "Settings": """Warn when CORS allows all origins in non-debug (production) mode.""" if not self.debug and self.cors_origins and "*" in self.cors_origins: logging.getLogger(__name__).warning( "CORS allows all origins (*) with DEBUG=false. " "Set CORS_ORIGINS to exact origins in production." ) return self # AWS S3 Asset Delivery (drum kits, GM soundfont) # Region MUST match the bucket's region (S3 returns 301 if URL uses wrong region). aws_region: str = "eu-west-1" aws_s3_asset_bucket: str | None = None aws_cloudfront_domain: str | None = None presign_expiry_seconds: int = 1800 # 30-min default for presigned download URLs # S3-compatible blob storage (Cloudflare R2, MinIO, AWS S3, etc.). # When blob_storage_bucket is set it is used for all muse object blobs. blob_storage_bucket: str | None = None blob_storage_endpoint: str | None = None # e.g. https://.r2.cloudflarestorage.com or http://minio:9000 blob_storage_public_endpoint: str | None = None # public URL for presigned URLs (local dev: http://localhost:9000) blob_storage_cdn_base_url: str | None = None # CDN origin for mpack GET URLs (e.g. https://cdn.musehub.ai) blob_storage_access_key_id: str | None = None blob_storage_secret_access_key: str | None = None blob_storage_region: str = "auto" # Asset endpoint rate limits (device-ID auth) asset_rate_limit_per_device: str = "30/minute" asset_rate_limit_per_ip: str = "120/minute" # MCP rate limits — agents get a higher tier than anonymous/human callers. # Agent identities have `identity_type == "agent"` in the DB. mcp_rate_limit_human: str = "60/minute" mcp_rate_limit_agent: str = "600/minute" mcp_rate_limit_anonymous: str = "20/minute" # Database connection pool — pool_timeout is how long to wait for a # connection from the pool before raising TimeoutError. db_pool_timeout: int = 30 # seconds # Slow query log threshold. Any SQL statement taking longer than this # many milliseconds is logged at WARNING level with the full statement # and elapsed time. Set to 0 to disable. slow_query_threshold_ms: int = 100 # Per-user storage quota enforced at the MCP muse_push layer. # Agents that loop indefinitely cannot fill the disk beyond this limit. # Set to 0 to disable quota enforcement (not recommended in production). mcp_push_per_user_quota_bytes: int = 10 * 1024 * 1024 * 1024 # 10 GB default # Per-repo storage quota enforced at both MCP and wire push layers. # Prevents a single repo from monopolising disk regardless of user quota. # Set to 0 to disable. per_repo_quota_bytes: int = 5 * 1024 * 1024 * 1024 # 5 GB default # MPack push size gates — enforced at the route layer before any storage I/O. mpack_max_bytes: int = 512 * 1024 * 1024 # 512 MB per mpack mpack_max_commits: int = 100_000 # commits per mpack push mpack_max_objects: int = 1_000_000 # objects per mpack push # Sync-path content_cache threshold. Mpacks at or below this size have their # objects written inline (content_cache=raw_bytes, storage_uri='pending') so # fetch requests are served immediately without waiting for the background job. # Mpacks above this threshold skip inline writes; the background job handles them. mpack_content_cache_max_bytes: int = 4 * 1024 * 1024 # 4 MB # Maximum total decompressed size for all objects in an mpack. # Mpacks that exceed this limit during decompression are quarantined as # potential zip bombs (Phase 2 content validation). mpack_max_decompressed_bytes: int = 4 * 1024 * 1024 * 1024 # 4 GB # Per-user daily mpack upload byte limit (Phase 4a). # mpack-presign returns 429 when the caller's running daily total would # exceed this value. Set to 0 to disable enforcement. mpack_daily_upload_limit_bytes: int = 50 * 1024 * 1024 * 1024 # 50 GB default # Soft-delete retention window: objects are hard-deleted this many days # after their deleted_at timestamp is set. object_retention_days: int = 30 # Stdio MCP server: proxy DAW tools to Muse backend muse_mcp_url: str | None = None mcp_token: str | None = None musehub_releases_dir: str = "/data/releases" # Root directory for per-repo on-disk state (branch refs, MERGE_STATE, worktrees). # Objects are in the blob store (R2/MinIO) — not here. musehub_repos_dir: str = "/data/repos" # Webhook secret encryption key — AES-256 (Fernet) key for encrypting webhook signing # secrets at rest. Generate with: # python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" webhook_secret_key: str | None = None # Commit signature enforcement. # When True, wire_push rejects any commit that does not carry a non-empty # ``signature`` and ``signer_key_id``. Recommended for production repos # where all contributors have registered signing keys. # Default False for backward compatibility with existing unsigned commits. require_signed_commits: bool = False # Agent identity registry for impersonation detection. # A JSON list of known/trusted agent_id prefixes or exact IDs # (e.g. '["agentception-worker", "claude-opus-4-6"]'). # When non-empty, commits whose agent_id does NOT match any entry are # accepted but flagged in the commit metadata with "untrusted_agent": true. # Unknown agents are NEVER rejected — only flagged. # Leave empty (default) to accept all agent_ids without flagging. trusted_agent_ids: list[str] = [] @model_validator(mode="after") def _warn_missing_production_secrets(self) -> "Settings": """Warn at startup when optional-but-recommended secrets are absent in production.""" is_prod = not self.debug and self.muse_env not in ("test", "development") if not is_prod: return self _log = logging.getLogger(__name__) if not self.webhook_secret_key: _log.warning( "WEBHOOK_SECRET_KEY is not set — webhook delivery will be disabled. " "Generate with: python3 -c \"from cryptography.fernet import Fernet; " "print(Fernet.generate_key().decode())\"" ) return self model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", # silently discard unknown env vars (e.g. OPENROUTER_API_KEY from other tools) ) @lru_cache() def get_settings() -> Settings: """Get cached settings instance.""" return Settings() # Convenience access settings = get_settings()