gabriel / musehub public
config.py python
195 lines 8.6 KB
Raw
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12 refactor: rename 0054/0055 migrations to standard convention Sonnet 4.6 minor ⚠ breaking 21 days ago
1 """
2 Muse Configuration
3
4 Environment-based configuration for the Muse service.
5 """
6
7 import logging
8 from functools import lru_cache
9
10 from pydantic import model_validator
11 from pydantic_settings import BaseSettings, SettingsConfigDict
12
13
14 def _app_version_from_package() -> str:
15 """Read version from the single source of truth (pyproject.toml via protocol.version)."""
16 from musehub.protocol.version import MUSE_VERSION
17 return MUSE_VERSION
18
19
20 class Settings(BaseSettings):
21 """Application settings loaded from environment variables."""
22
23 # Service Info
24 app_name: str = "Muse"
25 app_version: str = _app_version_from_package()
26 debug: bool = False
27 muse_env: str = "production" # "test" | "development" | "production"
28
29 # Public base URL — used to build clone URLs returned by the API and MCP tools.
30 # Override via PUBLIC_URL env var on staging/local: e.g. https://staging.musehub.ai
31 public_url: str = "https://musehub.ai"
32
33 # Allowlist of Host header values that the server will trust when building
34 # request-derived URLs (e.g. clone URLs on the repo home page).
35 # Any Host value not in this set falls back to public_url.
36 # Override via ALLOWED_HOSTS env var (JSON array):
37 # ALLOWED_HOSTS='["musehub.ai","staging.musehub.ai","localhost:1337"]'
38 allowed_hosts: list[str] = ["musehub.ai", "staging.musehub.ai", "localhost:1337"]
39
40 # Server Configuration
41 host: str = "0.0.0.0"
42 port: int = 10001
43
44 # Database Configuration — PostgreSQL only
45 # Example: postgresql+asyncpg://user:pass@localhost:5432/musehub
46 database_url: str | None = None
47 db_password: str | None = None
48
49 # CORS Settings (fail closed: no default origins)
50 # Set CORS_ORIGINS (JSON array) in .env. Local dev: ["https://localhost:1337", "muse://"].
51 # Production: exact origins only. Never use "*" in production.
52 cors_origins: list[str] = []
53
54 @model_validator(mode="after")
55 def _warn_cors_wildcard_in_production(self) -> "Settings":
56 """Warn when CORS allows all origins in non-debug (production) mode."""
57 if not self.debug and self.cors_origins and "*" in self.cors_origins:
58 logging.getLogger(__name__).warning(
59 "CORS allows all origins (*) with DEBUG=false. "
60 "Set CORS_ORIGINS to exact origins in production."
61 )
62 return self
63
64 # AWS S3 Asset Delivery (drum kits, GM soundfont)
65 # Region MUST match the bucket's region (S3 returns 301 if URL uses wrong region).
66 aws_region: str = "eu-west-1"
67 aws_s3_asset_bucket: str | None = None
68 aws_cloudfront_domain: str | None = None
69 presign_expiry_seconds: int = 1800 # 30-min default for presigned download URLs
70
71 # S3-compatible blob storage (Cloudflare R2, MinIO, AWS S3, etc.).
72 # When blob_storage_bucket is set it is used for all muse object blobs.
73 blob_storage_bucket: str | None = None
74 blob_storage_endpoint: str | None = None # e.g. https://<account>.r2.cloudflarestorage.com or http://minio:9000
75 blob_storage_public_endpoint: str | None = None # public URL for presigned URLs (local dev: http://localhost:9000)
76 blob_storage_cdn_base_url: str | None = None # CDN origin for mpack GET URLs (e.g. https://cdn.musehub.ai)
77 blob_storage_access_key_id: str | None = None
78 blob_storage_secret_access_key: str | None = None
79 blob_storage_region: str = "auto"
80
81 # Asset endpoint rate limits (device-ID auth)
82 asset_rate_limit_per_device: str = "30/minute"
83 asset_rate_limit_per_ip: str = "120/minute"
84
85 # MCP rate limits — agents get a higher tier than anonymous/human callers.
86 # Agent identities have `identity_type == "agent"` in the DB.
87 mcp_rate_limit_human: str = "60/minute"
88 mcp_rate_limit_agent: str = "600/minute"
89 mcp_rate_limit_anonymous: str = "20/minute"
90
91 # Database connection pool — pool_timeout is how long to wait for a
92 # connection from the pool before raising TimeoutError.
93 db_pool_timeout: int = 30 # seconds
94
95 # Slow query log threshold. Any SQL statement taking longer than this
96 # many milliseconds is logged at WARNING level with the full statement
97 # and elapsed time. Set to 0 to disable.
98 slow_query_threshold_ms: int = 100
99
100 # Per-user storage quota enforced at the MCP muse_push layer.
101 # Agents that loop indefinitely cannot fill the disk beyond this limit.
102 # Set to 0 to disable quota enforcement (not recommended in production).
103 mcp_push_per_user_quota_bytes: int = 10 * 1024 * 1024 * 1024 # 10 GB default
104
105 # Per-repo storage quota enforced at both MCP and wire push layers.
106 # Prevents a single repo from monopolising disk regardless of user quota.
107 # Set to 0 to disable.
108 per_repo_quota_bytes: int = 5 * 1024 * 1024 * 1024 # 5 GB default
109
110 # MPack push size gates — enforced at the route layer before any storage I/O.
111 mpack_max_bytes: int = 512 * 1024 * 1024 # 512 MB per mpack
112 mpack_max_commits: int = 100_000 # commits per mpack push
113 mpack_max_objects: int = 1_000_000 # objects per mpack push
114
115 # Sync-path content_cache threshold. Mpacks at or below this size have their
116 # objects written inline (content_cache=raw_bytes, storage_uri='pending') so
117 # fetch requests are served immediately without waiting for the background job.
118 # Mpacks above this threshold skip inline writes; the background job handles them.
119 mpack_content_cache_max_bytes: int = 4 * 1024 * 1024 # 4 MB
120
121 # Maximum total decompressed size for all objects in an mpack.
122 # Mpacks that exceed this limit during decompression are quarantined as
123 # potential zip bombs (Phase 2 content validation).
124 mpack_max_decompressed_bytes: int = 4 * 1024 * 1024 * 1024 # 4 GB
125
126 # Per-user daily mpack upload byte limit (Phase 4a).
127 # mpack-presign returns 429 when the caller's running daily total would
128 # exceed this value. Set to 0 to disable enforcement.
129 mpack_daily_upload_limit_bytes: int = 50 * 1024 * 1024 * 1024 # 50 GB default
130
131 # Soft-delete retention window: objects are hard-deleted this many days
132 # after their deleted_at timestamp is set.
133 object_retention_days: int = 30
134
135 # Stdio MCP server: proxy DAW tools to Muse backend
136 muse_mcp_url: str | None = None
137 mcp_token: str | None = None
138
139 musehub_releases_dir: str = "/data/releases"
140
141 # Root directory for per-repo on-disk state (branch refs, MERGE_STATE, worktrees).
142 # Objects are in the blob store (R2/MinIO) — not here.
143 musehub_repos_dir: str = "/data/repos"
144
145 # Webhook secret encryption key — AES-256 (Fernet) key for encrypting webhook signing
146 # secrets at rest. Generate with:
147 # python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
148 webhook_secret_key: str | None = None
149
150 # Commit signature enforcement.
151 # When True, wire_push rejects any commit that does not carry a non-empty
152 # ``signature`` and ``signer_key_id``. Recommended for production repos
153 # where all contributors have registered signing keys.
154 # Default False for backward compatibility with existing unsigned commits.
155 require_signed_commits: bool = False
156
157 # Agent identity registry for impersonation detection.
158 # A JSON list of known/trusted agent_id prefixes or exact IDs
159 # (e.g. '["agentception-worker", "claude-opus-4-6"]').
160 # When non-empty, commits whose agent_id does NOT match any entry are
161 # accepted but flagged in the commit metadata with "untrusted_agent": true.
162 # Unknown agents are NEVER rejected — only flagged.
163 # Leave empty (default) to accept all agent_ids without flagging.
164 trusted_agent_ids: list[str] = []
165
166 @model_validator(mode="after")
167 def _warn_missing_production_secrets(self) -> "Settings":
168 """Warn at startup when optional-but-recommended secrets are absent in production."""
169 is_prod = not self.debug and self.muse_env not in ("test", "development")
170 if not is_prod:
171 return self
172 _log = logging.getLogger(__name__)
173 if not self.webhook_secret_key:
174 _log.warning(
175 "WEBHOOK_SECRET_KEY is not set — webhook delivery will be disabled. "
176 "Generate with: python3 -c \"from cryptography.fernet import Fernet; "
177 "print(Fernet.generate_key().decode())\""
178 )
179 return self
180
181 model_config = SettingsConfigDict(
182 env_file=".env",
183 env_file_encoding="utf-8",
184 extra="ignore", # silently discard unknown env vars (e.g. OPENROUTER_API_KEY from other tools)
185 )
186
187
188 @lru_cache()
189 def get_settings() -> Settings:
190 """Get cached settings instance."""
191 return Settings()
192
193
194 # Convenience access
195 settings = get_settings()
File History 2 commits
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12 refactor: rename 0054/0055 migrations to standard convention Sonnet 4.6 minor 21 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 23 days ago