from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any import tomllib from .route_manifest import load_route_manifest DEFAULT_DAEMONS = { "clearnet": "nginx", "tor": "tor", "i2p": "i2pd", "gemini": "agate", "reticulum": "rnsd", "ipfs": "ipfs", } DEFAULT_POLICIES = { "clearnet": "adopt", "tor": "auto", "i2p": "auto", "gemini": "auto", "reticulum": "auto", "ipfs": "auto", } @dataclass(frozen=True) class SourceConfig: kind: str path: Path canonical_url: str | None = None @dataclass(frozen=True) class OutputConfig: root: Path plan_root: Path @dataclass(frozen=True) class ProtocolConfig: name: str enabled: bool renderer: str daemon: str daemon_policy: str options: dict[str, Any] = field(default_factory=dict) @dataclass(frozen=True) class RoutePolicyConfig: match: str tier: str identity: str = "none" payments: str = "none" realtime: bool = False public_allowed: bool = True source: str = "config" @dataclass(frozen=True) class InteractionConfig: default_tier: str = "static" deny_routes: tuple[str, ...] = () routes: tuple[RoutePolicyConfig, ...] = () route_manifest: Path | None = None @dataclass(frozen=True) class SiteConfig: id: str domain: str source: SourceConfig outputs: OutputConfig protocols: dict[str, ProtocolConfig] interactions: InteractionConfig @dataclass(frozen=True) class ProfileConfig: name: str protocols: tuple[str, ...] = () platform: str | None = None write_artifacts: bool = False description: str = "" @dataclass(frozen=True) class GatewayPaths: state_dir: Path cache_dir: Path run_dir: Path user: str | None = None @dataclass(frozen=True) class GatewayConfig: config_path: Path sites: list[SiteConfig] profiles: dict[str, ProfileConfig] = field(default_factory=dict) paths: GatewayPaths | None = None def load_config(path: Path) -> GatewayConfig: config_path = path.resolve() data = tomllib.loads(config_path.read_text(encoding="utf-8")) sites = [_parse_site(raw_site, config_path.parent) for raw_site in data.get("site", [])] if not sites: raise ValueError("config must declare at least one [[site]]") profiles = _parse_profiles(data.get("profiles", {})) paths = _parse_gateway_paths(data.get("gateway", {}), config_path.parent) return GatewayConfig(config_path=config_path, sites=sites, profiles=profiles, paths=paths) def _parse_gateway_paths(raw_gateway: Any, base_dir: Path) -> GatewayPaths: if raw_gateway is None: raw_gateway = {} if not isinstance(raw_gateway, dict): raise ValueError("gateway must be a table") user = raw_gateway.get("user") if user is not None and not isinstance(user, str): raise ValueError("gateway.user must be a string") return GatewayPaths( state_dir=_resolve_path(base_dir, str(raw_gateway.get("state_dir", "./.ampg/state"))), cache_dir=_resolve_path(base_dir, str(raw_gateway.get("cache_dir", "./.ampg/cache"))), run_dir=_resolve_path(base_dir, str(raw_gateway.get("run_dir", "./.ampg/run"))), user=user, ) def _parse_profiles(raw_profiles: Any) -> dict[str, ProfileConfig]: if raw_profiles is None: return {} if not isinstance(raw_profiles, dict): raise ValueError("profiles must be a table") profiles: dict[str, ProfileConfig] = {} for name, raw_profile in raw_profiles.items(): if not isinstance(raw_profile, dict): raise ValueError(f"profile {name!r} must be a table") platform = raw_profile.get("platform") if platform is not None and not isinstance(platform, str): raise ValueError(f"profile {name!r}.platform must be a string") description = raw_profile.get("description", "") if not isinstance(description, str): raise ValueError(f"profile {name!r}.description must be a string") profiles[str(name)] = ProfileConfig( name=str(name), protocols=_string_array( raw_profile.get("protocols", ()), f"profile {name!r}.protocols", ), platform=platform, write_artifacts=bool(raw_profile.get("write_artifacts", False)), description=description, ) return profiles def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig: source = raw_site.get("source") or {} outputs = raw_site.get("outputs") or {} protocols = raw_site.get("protocols") or {} interactions = raw_site.get("interactions") or {} site_id = _required_str(raw_site, "id") domain = _required_str(raw_site, "domain") source_kind = _required_str(source, "kind") if source_kind != "static-html": raise ValueError(f"{site_id}: unsupported source kind {source_kind!r}") source_path = _resolve_path(base_dir, _required_str(source, "path")) output_root = _resolve_path(base_dir, _required_str(outputs, "root")) plan_root = _resolve_path(base_dir, str(outputs.get("plan_root", "../dist/ampg-plan"))) parsed_protocols = { name: _parse_protocol(name, raw_protocol) for name, raw_protocol in protocols.items() if isinstance(raw_protocol, dict) } return SiteConfig( id=site_id, domain=domain, source=SourceConfig( kind=source_kind, path=source_path, canonical_url=source.get("canonical_url"), ), outputs=OutputConfig(root=output_root, plan_root=plan_root), protocols=parsed_protocols, interactions=_parse_interactions(interactions, base_dir, site_id), ) def _parse_protocol(name: str, raw_protocol: dict[str, Any]) -> ProtocolConfig: enabled = bool(raw_protocol.get("enabled", False)) renderer = str(raw_protocol.get("renderer", name)) daemon = str(raw_protocol.get("daemon", DEFAULT_DAEMONS.get(name, name))) policy = str(raw_protocol.get("daemon_policy", DEFAULT_POLICIES.get(name, "auto"))) options = dict(raw_protocol) for key in ("enabled", "renderer", "daemon", "daemon_policy"): options.pop(key, None) return ProtocolConfig( name=name, enabled=enabled, renderer=renderer, daemon=daemon, daemon_policy=policy, options=options, ) def _parse_interactions( raw_interactions: dict[str, Any], base_dir: Path, site_id: str, ) -> InteractionConfig: if not isinstance(raw_interactions, dict): raise ValueError("site.interactions must be a table") route_manifest = _route_manifest_path(raw_interactions, base_dir) manifest_data = load_route_manifest(route_manifest, site_id=site_id) if route_manifest else {} default_tier = str( raw_interactions.get("default_tier", manifest_data.get("default_tier", "static")) ) deny_routes = _string_array(manifest_data.get("deny_routes", ()), "route manifest deny_routes") deny_routes += _string_array(raw_interactions.get("deny_routes", ()), "site.interactions.deny_routes") routes = tuple( _parse_route_policy(raw_route, default_tier, source="route-manifest") for raw_route in _route_array(manifest_data.get("routes", ()), "route manifest routes") ) routes += tuple( _parse_route_policy(raw_route, default_tier, source="config") for raw_route in _route_array(raw_interactions.get("route", ()), "site.interactions.route") ) return InteractionConfig( default_tier=default_tier, deny_routes=tuple(deny_routes), routes=routes, route_manifest=route_manifest, ) def _parse_route_policy( raw_route: dict[str, Any], default_tier: str, *, source: str, ) -> RoutePolicyConfig: if not isinstance(raw_route, dict): raise ValueError("route policy entries must be objects") return RoutePolicyConfig( match=_required_str(raw_route, "match"), tier=str(raw_route.get("tier", default_tier)), identity=str(raw_route.get("identity", "none")), payments=str(raw_route.get("payments", "none")), realtime=bool(raw_route.get("realtime", False)), public_allowed=bool(raw_route.get("public_allowed", True)), source=source, ) def _route_manifest_path(raw_interactions: dict[str, Any], base_dir: Path) -> Path | None: raw_path = raw_interactions.get("route_manifest") if raw_path is None: return None if not isinstance(raw_path, str) or not raw_path: raise ValueError("site.interactions.route_manifest must be a path string") return _resolve_path(base_dir, raw_path) def _route_array(raw_routes: Any, context: str) -> tuple[Any, ...]: if isinstance(raw_routes, dict): return (raw_routes,) if raw_routes is None: return () if not isinstance(raw_routes, (list, tuple)): raise ValueError(f"{context} must be an array") return tuple(raw_routes) def _string_array(raw_values: Any, context: str) -> tuple[str, ...]: if raw_values is None: return () if not isinstance(raw_values, (list, tuple)): raise ValueError(f"{context} must be an array") return tuple(str(value) for value in raw_values) def _resolve_path(base_dir: Path, raw_path: str) -> Path: path = Path(raw_path).expanduser() if not path.is_absolute(): path = base_dir / path return path.resolve() def _required_str(data: dict[str, Any], key: str) -> str: value = data.get(key) if not isinstance(value, str) or not value: raise ValueError(f"missing required string field {key!r}") return value