"""Hub-owned gateway adapter contracts without public gateway IDs.""" from __future__ import annotations import os import re from collections.abc import Mapping from datetime import UTC, datetime from pathlib import Path from typing import Any, Literal, cast from uuid import UUID from pydantic import ( AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, field_validator, model_validator, ) from packages.hub_runtime import SecretFileError, read_secret_file from .errors import ContextForgeError class ContextForgeSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) base_url: AnyHttpUrl auth_token_env: str | None = Field(default=None, min_length=1, max_length=256) auth_token_file: Path | None = None auth_scheme: Literal["Bearer", "Basic"] = "Bearer" basic_username: str = Field(default="itworx-contextforge-admin", min_length=1, max_length=256) timeout_seconds: float = Field(default=5.0, gt=0, le=30) retries: int = Field(default=2, ge=0, le=5) max_response_bytes: int = Field(default=4 * 1024 * 1024, ge=1024, le=4 * 1024 * 1024) @classmethod def from_env(cls, environ: Mapping[str, str] | None = None) -> ContextForgeSettings: values = environ if environ is not None else os.environ base_url = values.get("HUB_CONTEXTFORGE_URL") if not base_url: raise ContextForgeError("gateway_configuration_missing", "Gateway URL is unavailable.") try: return cls( base_url=TypeAdapter(AnyHttpUrl).validate_python(base_url), auth_token_env=( values.get("HUB_CONTEXTFORGE_TOKEN_ENV") if values.get("HUB_CONTEXTFORGE_TOKEN_FILE") else values.get("HUB_CONTEXTFORGE_TOKEN_ENV", "HUB_CONTEXTFORGE_TOKEN") ), auth_token_file=( Path(values["HUB_CONTEXTFORGE_TOKEN_FILE"]) if values.get("HUB_CONTEXTFORGE_TOKEN_FILE") else None ), auth_scheme=cast( Literal["Bearer", "Basic"], values.get("HUB_CONTEXTFORGE_AUTH_SCHEME", "Bearer"), ), basic_username=values.get( "HUB_CONTEXTFORGE_BASIC_USERNAME", "itworx-contextforge-admin" ), timeout_seconds=float(values.get("HUB_CONTEXTFORGE_TIMEOUT_SECONDS", "5")), retries=int(values.get("HUB_CONTEXTFORGE_RETRIES", "2")), ) except (TypeError, ValueError) as exc: raise ContextForgeError( "gateway_configuration_invalid", "Gateway configuration is invalid." ) from exc @model_validator(mode="after") def exactly_one_credential_source(self) -> ContextForgeSettings: if bool(self.auth_token_env) == bool(self.auth_token_file): raise ValueError("configure exactly one ContextForge credential source") return self def credential(self, environ: Mapping[str, str] | None = None) -> str: if self.auth_token_file: try: return read_secret_file(self.auth_token_file) except SecretFileError as exc: raise ContextForgeError( "gateway_credentials_missing", "Gateway authorization is unavailable." ) from exc value = (environ or os.environ).get(self.auth_token_env or "") if not value: raise ContextForgeError( "gateway_credentials_missing", "Gateway authorization is unavailable." ) return value class GatewayDesiredState(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) slug: str = Field( min_length=1, max_length=63, pattern=r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$" ) url: AnyHttpUrl description: str = Field(default="", max_length=1000) transport: Literal["STREAMABLEHTTP", "SSE", "STDIO", "WEBSOCKET"] = "STREAMABLEHTTP" enabled: bool = True passthrough_headers: tuple[str, ...] = () @field_validator("passthrough_headers") @classmethod def validate_passthrough_headers(cls, value: tuple[str, ...]) -> tuple[str, ...]: allowed = { "x-itworx-project", "x-itworx-environment", "x-itworx-client-id", "x-itworx-trace-id", "x-itworx-identity-assertion", } normalized = tuple(dict.fromkeys(item.strip().lower() for item in value)) if any(not re.fullmatch(r"[a-z0-9-]{1,64}", item) for item in normalized): raise ValueError("invalid passthrough header") if not set(normalized).issubset(allowed): raise ValueError("gateway passthrough header is not approved") return normalized class GatewaySnapshot(BaseModel): """Publicly safe gateway state; `external_id` is intentionally absent.""" model_config = ConfigDict(extra="forbid", frozen=True) slug: str url: str description: str transport: str enabled: bool status: str passthrough_headers: tuple[str, ...] = () class GatewayHandle(BaseModel): """Adapter-internal handle; do not serialize into public Hub contracts.""" model_config = ConfigDict(extra="forbid", frozen=True) external_id: str = Field(min_length=1, max_length=256) snapshot: GatewaySnapshot class GatewayTool(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) external_id: str = Field(min_length=1, max_length=256) name: str = Field(min_length=1, max_length=256) published_name: str = Field(min_length=1, max_length=256) input_schema: dict[str, Any] | None = None class GatewayPrompt(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) external_id: str = Field(min_length=1, max_length=256) name: str = Field(min_length=1, max_length=256) class VirtualServerDesiredState(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) external_id: UUID name: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,62}$") description: str = Field(max_length=1000) associated_tool_ids: tuple[str, ...] = Field(min_length=1) associated_prompt_ids: tuple[str, ...] = () # ContextForge's service token is intentionally public-only. The virtual # server is reachable solely on the private Docker network; the public Hub # dataplane remains the OAuth, binding, policy and rate-limit boundary. visibility: Literal["public"] = "public" @field_validator("associated_tool_ids") @classmethod def unique_tool_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]: if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value): raise ValueError("virtual server tool IDs must be unique bounded values") return tuple(sorted(value)) @field_validator("associated_prompt_ids") @classmethod def unique_prompt_ids(cls, value: tuple[str, ...]) -> tuple[str, ...]: if len(set(value)) != len(value) or any(not item or len(item) > 256 for item in value): raise ValueError("virtual server prompt IDs must be unique bounded values") return tuple(sorted(value)) class VirtualServerHandle(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) external_id: str = Field(min_length=1, max_length=256) name: str = Field(min_length=1, max_length=63) associated_tool_ids: tuple[str, ...] associated_prompt_ids: tuple[str, ...] = () enabled: bool visibility: Literal["public", "private", "team"] class GatewayTestResult(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) reachable: bool status: str detail: str = Field(default="", max_length=256) class GatewayCapabilities(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) health: bool readiness: bool version: str | None gateway_crud: bool gateway_test: bool class SanitizedGatewayInventory(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) generated_at: datetime capabilities: GatewayCapabilities gateways: tuple[dict[str, Any], ...] @field_validator("generated_at") @classmethod def require_aware_timestamp(cls, value: datetime) -> datetime: if value.tzinfo is None or value.utcoffset() is None: raise ValueError("inventory timestamp must be timezone-aware") return value.astimezone(UTC)