"""Environment-derived configuration. Loaded once at process start. Every module that needs a value pulls it from here rather than re-reading os.environ, so there is one obvious place to look when a setting is missing. """ from __future__ import annotations import os from dataclasses import dataclass, field from pathlib import Path from dotenv import load_dotenv load_dotenv() def _required(name: str) -> str: value = os.environ.get(name, "").strip() if not value: raise RuntimeError(f"Required environment variable {name} is not set") return value def _optional(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() @dataclass class Config: gitea_url: str gitea_bot_user: str gitea_bot_token: str gitea_org: str meta_repo: str registry_repo: str oauth_client_id: str oauth_client_secret: str app_url: str secret_key: str database_path: Path owner_gitea_login: str webhook_secret: str enabled_models: list[str] = field(default_factory=list) anthropic_api_key: str = "" google_api_key: str = "" openai_api_key: str = "" @property def redirect_uri(self) -> str: return f"{self.app_url}/auth/callback" @property def meta_repo_full(self) -> str: return f"{self.gitea_org}/{self.meta_repo}" def load_config() -> Config: database_path = Path(_optional("DATABASE_PATH", "data/rfc-app.db")).resolve() database_path.parent.mkdir(parents=True, exist_ok=True) enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()] # v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the # email + webhook hygiene proposal). An empty value used to # silently accept unsigned webhook POSTs — that was the # invisible-failure shape the proposal targets. Now the # framework refuses to start when the secret is empty unless # the operator opts into the dev-bypass with # `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without # a wired Gitea hook set the bypass; production MUST NOT. insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1" if insecure_webhooks: webhook_secret = _optional("GITEA_WEBHOOK_SECRET") else: webhook_secret = _required("GITEA_WEBHOOK_SECRET") return Config( gitea_url=_required("GITEA_URL").rstrip("/"), gitea_bot_user=_required("GITEA_BOT_USER"), gitea_bot_token=_required("GITEA_BOT_TOKEN"), gitea_org=_required("GITEA_ORG"), meta_repo=_optional("META_REPO", "meta"), # §22.2: the multi-project registry repo the framework reads to learn # which projects exist. Optional through Slice M1 — the registry # *mirror* lands in M3; until then the default project (seeded by # migration 025 + the §22.13 startup backfill) is the only project, # and its content_repo comes from META_REPO. Becomes required when # the mirror lands and META_REPO is retired. registry_repo=_optional("REGISTRY_REPO"), oauth_client_id=_required("OAUTH_CLIENT_ID"), oauth_client_secret=_required("OAUTH_CLIENT_SECRET"), app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"), secret_key=_required("SECRET_KEY"), database_path=database_path, owner_gitea_login=_optional("OWNER_GITEA_LOGIN"), webhook_secret=webhook_secret, enabled_models=enabled, anthropic_api_key=_optional("ANTHROPIC_API_KEY"), google_api_key=_optional("GOOGLE_API_KEY"), openai_api_key=_optional("OPENAI_API_KEY"), )