"""Load and validate the web UI project registry (#427).""" from __future__ import annotations import json import os from dataclasses import dataclass from pathlib import Path from typing import Any _FORBIDDEN_EXACT_KEYS = frozenset({ "token", "password", "secret", "credential", "auth", "api_key", "api-key", }) _FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_") _FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth") def _is_forbidden_key(key: str) -> bool: lowered = key.lower() if lowered in _FORBIDDEN_EXACT_KEYS: return True return ( lowered.startswith(_FORBIDDEN_KEY_PREFIXES) or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES) ) _REQUIRED_PROJECT_FIELDS = ( "id", "repo_name", "gitea_owner", "remote_host", "default_branch", "local_checkout_path", "profiles", "workflow_paths", ) _REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler") @dataclass(frozen=True) class OnboardingStep: id: str title: str description: str @dataclass(frozen=True) class ProjectRecord: id: str repo_name: str gitea_owner: str remote_host: str default_branch: str local_checkout_path: str profiles: dict[str, str] workflow_paths: dict[str, str] schema_paths: dict[str, str] onboarding_checklist: tuple[OnboardingStep, ...] @dataclass(frozen=True) class ProjectRegistry: version: int projects: tuple[ProjectRecord, ...] source_path: Path def default_registry_path() -> Path: override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip() if override: return Path(override).expanduser().resolve() return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve() def _reject_credential_keys(obj: Any, *, path: str = "") -> None: if isinstance(obj, dict): for key, value in obj.items(): key_path = f"{path}.{key}" if path else key if _is_forbidden_key(key): raise ValueError(f"registry must not store credentials ({key_path})") _reject_credential_keys(value, path=key_path) elif isinstance(obj, list): for index, item in enumerate(obj): _reject_credential_keys(item, path=f"{path}[{index}]") def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]: if not raw: return () steps: list[OnboardingStep] = [] for item in raw: steps.append( OnboardingStep( id=str(item["id"]), title=str(item["title"]), description=str(item["description"]), ) ) return tuple(steps) def _parse_project(raw: dict[str, Any]) -> ProjectRecord: missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw] if missing: raise ValueError(f"project missing required fields: {', '.join(missing)}") profiles = raw["profiles"] if not isinstance(profiles, dict): raise ValueError("profiles must be an object") for role in _REQUIRED_PROFILE_ROLES: if role not in profiles or not profiles[role]: raise ValueError(f"profiles.{role} is required") workflow_paths = raw["workflow_paths"] if not isinstance(workflow_paths, dict) or not workflow_paths: raise ValueError("workflow_paths must be a non-empty object") schema_paths = raw.get("schema_paths") or {} if not isinstance(schema_paths, dict): raise ValueError("schema_paths must be an object when present") return ProjectRecord( id=str(raw["id"]), repo_name=str(raw["repo_name"]), gitea_owner=str(raw["gitea_owner"]), remote_host=str(raw["remote_host"]), default_branch=str(raw["default_branch"]), local_checkout_path=str(raw["local_checkout_path"]), profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES}, workflow_paths={key: str(value) for key, value in workflow_paths.items()}, schema_paths={key: str(value) for key, value in schema_paths.items()}, onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")), ) def load_registry(path: Path | None = None) -> ProjectRegistry: """Load the versioned project registry from disk.""" source = (path or default_registry_path()).resolve() raw_text = source.read_text(encoding="utf-8") payload = json.loads(raw_text) if not isinstance(payload, dict): raise ValueError("registry root must be an object") version = payload.get("version") if version != 1: raise ValueError(f"unsupported registry version: {version!r}") _reject_credential_keys(payload) projects_raw = payload.get("projects") if not isinstance(projects_raw, list) or not projects_raw: raise ValueError("projects must be a non-empty array") projects = tuple(_parse_project(item) for item in projects_raw) return ProjectRegistry(version=version, projects=projects, source_path=source) def project_to_dict(project: ProjectRecord) -> dict[str, Any]: """Serialize a project for JSON API responses.""" return { "id": project.id, "repo_name": project.repo_name, "gitea_owner": project.gitea_owner, "remote_host": project.remote_host, "default_branch": project.default_branch, "local_checkout_path": project.local_checkout_path, "profiles": dict(project.profiles), "workflow_paths": dict(project.workflow_paths), "schema_paths": dict(project.schema_paths), "onboarding_checklist": [ {"id": step.id, "title": step.title, "description": step.description} for step in project.onboarding_checklist ], } def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]: return { "version": registry.version, "source_path": str(registry.source_path), "projects": [project_to_dict(project) for project in registry.projects], } def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None: for project in registry.projects: if project.id == project_id: return project return None