"""Pydantic models for the dashboard widget system.""" from typing import Any from pydantic import BaseModel, Field, field_validator FORBIDDEN_CONFIG_KEYS = { "password", "token", "secret", "api_key", "apikey", "private_key", "passphrase", "credential", } def _looks_secret(value: Any) -> bool: """Heuristic to detect values that look like secrets/tokens.""" if not isinstance(value, str) or not value.strip(): return False lowered = value.lower() if value.startswith("sk-") or value.startswith("eyJ"): return True if len(value) > 64 and lowered.isalnum(): return True return False def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]: """Recursively reject credential keys and secret-looking values.""" for key, value in config.items(): if key.lower() in FORBIDDEN_CONFIG_KEYS: raise ValueError(f"Credential key '{key}' is not allowed in widget config") if _looks_secret(value): raise ValueError(f"Value for '{key}' looks like a secret") if isinstance(value, dict): _validate_config_keys(value) elif isinstance(value, list): for item in value: if isinstance(item, dict): _validate_config_keys(item) return config class _WidgetInstanceBase(BaseModel): """Shared fields between input and output widget models.""" addon_id: str widget_type: str title: str = Field(..., min_length=1) config: dict[str, Any] = Field(default_factory=dict) enabled: bool = True sort_order: int = Field(default=0, ge=0) @field_validator("config") @classmethod def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]: return _validate_config_keys(value or {}) class WidgetInstanceInput(_WidgetInstanceBase): """Payload for creating or updating a widget instance.""" id: str | None = None class WidgetInstance(_WidgetInstanceBase): """Persisted widget instance returned by the API.""" id: str created_at: int updated_at: int class WidgetTypeInfo(BaseModel): """Metadata about a built-in widget type.""" addon_id: str widget_type: str name: str description: str source_type: str config_schema: dict[str, Any] class WidgetDataResponse(BaseModel): """Response from the per-widget data endpoint.""" widget_id: str widget_type: str data: dict[str, Any] | None = None error: str | None = None fetched_at: int