mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Expand fixture manifests with route policies
This commit is contained in:
@@ -48,6 +48,23 @@ class ProtocolConfig:
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractionConfig:
|
||||
default_tier: str = "static"
|
||||
deny_routes: tuple[str, ...] = ()
|
||||
routes: tuple[RoutePolicyConfig, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteConfig:
|
||||
id: str
|
||||
@@ -55,6 +72,7 @@ class SiteConfig:
|
||||
source: SourceConfig
|
||||
outputs: OutputConfig
|
||||
protocols: dict[str, ProtocolConfig]
|
||||
interactions: InteractionConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -76,6 +94,7 @@ 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")
|
||||
@@ -103,6 +122,7 @@ def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig:
|
||||
),
|
||||
outputs=OutputConfig(root=output_root, plan_root=plan_root),
|
||||
protocols=parsed_protocols,
|
||||
interactions=_parse_interactions(interactions),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +144,35 @@ def _parse_protocol(name: str, raw_protocol: dict[str, Any]) -> ProtocolConfig:
|
||||
)
|
||||
|
||||
|
||||
def _parse_interactions(raw_interactions: dict[str, Any]) -> InteractionConfig:
|
||||
raw_routes = raw_interactions.get("route", ())
|
||||
if isinstance(raw_routes, dict):
|
||||
raw_routes = (raw_routes,)
|
||||
if not isinstance(raw_routes, (list, tuple)):
|
||||
raise ValueError("site.interactions.route must be a table array")
|
||||
deny_routes = raw_interactions.get("deny_routes", ())
|
||||
if not isinstance(deny_routes, (list, tuple)):
|
||||
raise ValueError("site.interactions.deny_routes must be an array")
|
||||
return InteractionConfig(
|
||||
default_tier=str(raw_interactions.get("default_tier", "static")),
|
||||
deny_routes=tuple(str(route) for route in deny_routes),
|
||||
routes=tuple(_parse_route_policy(raw_route) for raw_route in raw_routes),
|
||||
)
|
||||
|
||||
|
||||
def _parse_route_policy(raw_route: dict[str, Any]) -> RoutePolicyConfig:
|
||||
if not isinstance(raw_route, dict):
|
||||
raise ValueError("site.interactions.route entries must be tables")
|
||||
return RoutePolicyConfig(
|
||||
match=_required_str(raw_route, "match"),
|
||||
tier=str(raw_route.get("tier", "static")),
|
||||
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)),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_path(base_dir: Path, raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
|
||||
+67
-12
@@ -5,7 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||
from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig
|
||||
|
||||
|
||||
SCHEMA = "ampg.fixture-manifest.v1"
|
||||
@@ -19,11 +19,16 @@ class ManifestWriteResult:
|
||||
|
||||
|
||||
def fixture_manifest(site: SiteConfig) -> dict[str, Any]:
|
||||
fixtures = [
|
||||
_fixture_entry(site, protocol)
|
||||
for protocol in site.protocols.values()
|
||||
if protocol.enabled
|
||||
]
|
||||
fixtures = []
|
||||
for protocol in site.protocols.values():
|
||||
if not protocol.enabled:
|
||||
continue
|
||||
fixtures.append(_fixture_entry(site, protocol, None))
|
||||
fixtures.extend(
|
||||
_fixture_entry(site, protocol, route_policy)
|
||||
for route_policy in site.interactions.routes
|
||||
if _route_is_public(route_policy)
|
||||
)
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"site": {
|
||||
@@ -59,21 +64,31 @@ def manifest_path(site: SiteConfig) -> Path:
|
||||
return site.outputs.root / "ampg-fixture-manifest.json"
|
||||
|
||||
|
||||
def _fixture_entry(site: SiteConfig, protocol: ProtocolConfig) -> dict[str, Any]:
|
||||
def _fixture_entry(
|
||||
site: SiteConfig,
|
||||
protocol: ProtocolConfig,
|
||||
route_policy: RoutePolicyConfig | None,
|
||||
) -> dict[str, Any]:
|
||||
url, address_status = _fixture_url(site, protocol)
|
||||
transport = _transport_for(protocol.name)
|
||||
return {
|
||||
entry = {
|
||||
"protocol": protocol.name,
|
||||
"renderer": protocol.renderer,
|
||||
"url": url,
|
||||
"url": _url_for_route(url, route_policy),
|
||||
"address_status": address_status,
|
||||
"output_path": protocol.name,
|
||||
"output_path": _output_path_for_route(protocol.name, route_policy),
|
||||
"checks": {
|
||||
"transport": transport,
|
||||
"profile": transport,
|
||||
},
|
||||
"interaction": _interaction_policy(protocol),
|
||||
"interaction": _interaction_policy(protocol, route_policy),
|
||||
}
|
||||
if route_policy:
|
||||
entry["route"] = {
|
||||
"match": route_policy.match,
|
||||
"fixture_path": _fixture_path(route_policy.match),
|
||||
}
|
||||
return entry
|
||||
|
||||
|
||||
def _transport_for(protocol_name: str) -> str:
|
||||
@@ -82,7 +97,22 @@ def _transport_for(protocol_name: str) -> str:
|
||||
return protocol_name
|
||||
|
||||
|
||||
def _interaction_policy(protocol: ProtocolConfig) -> dict[str, Any]:
|
||||
def _route_is_public(route_policy: RoutePolicyConfig) -> bool:
|
||||
return route_policy.public_allowed and route_policy.tier != "internal"
|
||||
|
||||
|
||||
def _interaction_policy(
|
||||
protocol: ProtocolConfig,
|
||||
route_policy: RoutePolicyConfig | None,
|
||||
) -> dict[str, Any]:
|
||||
if route_policy:
|
||||
return {
|
||||
"tier": route_policy.tier,
|
||||
"identity": route_policy.identity,
|
||||
"payments": route_policy.payments,
|
||||
"realtime": route_policy.realtime,
|
||||
"public_allowed": route_policy.public_allowed,
|
||||
}
|
||||
return {
|
||||
"tier": str(protocol.options.get("tier", protocol.options.get("max_tier", "static"))),
|
||||
"identity": str(protocol.options.get("identity", "none")),
|
||||
@@ -92,6 +122,31 @@ def _interaction_policy(protocol: ProtocolConfig) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _url_for_route(base_url: str, route_policy: RoutePolicyConfig | None) -> str:
|
||||
if not route_policy:
|
||||
return base_url
|
||||
fixture_path = _fixture_path(route_policy.match)
|
||||
if base_url.startswith(("rns://", "lxmf://", "nomad://", "ipfs://", "ipns://")):
|
||||
return base_url
|
||||
return base_url.rstrip("/") + fixture_path
|
||||
|
||||
|
||||
def _output_path_for_route(protocol_name: str, route_policy: RoutePolicyConfig | None) -> str:
|
||||
if not route_policy:
|
||||
return protocol_name
|
||||
return protocol_name + _fixture_path(route_policy.match)
|
||||
|
||||
|
||||
def _fixture_path(match: str) -> str:
|
||||
value = match.strip()
|
||||
if not value.startswith("/"):
|
||||
value = "/" + value
|
||||
value = value.replace("*", "")
|
||||
if not value or value == "/":
|
||||
return "/"
|
||||
return value if value.endswith("/") else value + "/"
|
||||
|
||||
|
||||
def _fixture_url(site: SiteConfig, protocol: ProtocolConfig) -> tuple[str, str]:
|
||||
configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url")
|
||||
if configured_url:
|
||||
|
||||
@@ -37,6 +37,11 @@ tier = "transactional"
|
||||
identity = "http-session"
|
||||
payments = "server-invoice"
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/admin/*"
|
||||
tier = "internal"
|
||||
public_allowed = false
|
||||
|
||||
[site.protocols.clearnet]
|
||||
enabled = true
|
||||
renderer = "clearnet"
|
||||
|
||||
@@ -22,6 +22,7 @@ The manifest contains public route expectations only:
|
||||
- expected AMPB transport and profile.
|
||||
- declared interaction tier, identity adapter, payment adapter, realtime flag, and
|
||||
public exposure flag.
|
||||
- optional route-group metadata for configured public route policies.
|
||||
|
||||
It must not contain private host inventory, hidden-service keys, tunnel keys, credentials,
|
||||
or deployment notes.
|
||||
@@ -58,3 +59,7 @@ Fixture interaction policy defaults to:
|
||||
|
||||
Route-group overrides will be added separately. Until then, protocol-level options can
|
||||
declare the fixture-level policy that AMPB should check.
|
||||
|
||||
When `[[site.interactions.route]]` entries are configured, AMPG emits additional fixtures
|
||||
for public route groups. Routes with `tier = "internal"` or `public_allowed = false` are
|
||||
not emitted.
|
||||
|
||||
@@ -45,6 +45,10 @@ An application adapter describes what AMPG may expose:
|
||||
Adapters do not bypass the application. They translate constrained protocol input into the
|
||||
application's public action surface, then render safe responses.
|
||||
|
||||
Configured public route groups are emitted into fixture manifests so AMPB can verify the
|
||||
route and interaction policy for each selected transport. Internal route groups are not
|
||||
emitted.
|
||||
|
||||
## Protocol Mapping
|
||||
|
||||
| Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum |
|
||||
|
||||
@@ -129,6 +129,66 @@ payments = "server-invoice"
|
||||
manifest["fixtures"][0]["interaction"],
|
||||
)
|
||||
|
||||
def test_manifest_expands_public_route_policies(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
source = root / "site"
|
||||
source.mkdir()
|
||||
(source / "index.html").write_text("<h1>Game</h1>", encoding="utf-8")
|
||||
config_path = root / "gateway.toml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
[[site]]
|
||||
id = "game"
|
||||
domain = "game.test"
|
||||
|
||||
[site.source]
|
||||
kind = "static-html"
|
||||
path = "./site"
|
||||
canonical_url = "https://game.test"
|
||||
|
||||
[site.outputs]
|
||||
root = "./out"
|
||||
plan_root = "./plan"
|
||||
|
||||
[site.interactions]
|
||||
default_tier = "static"
|
||||
deny_routes = ["/admin/*"]
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/play/*"
|
||||
tier = "interactive-lite"
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/checkout/*"
|
||||
tier = "transactional"
|
||||
identity = "http-session"
|
||||
payments = "server-invoice"
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/admin/*"
|
||||
tier = "internal"
|
||||
public_allowed = false
|
||||
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
renderer = "privacy-html"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path)
|
||||
manifest = fixture_manifest(config.sites[0])
|
||||
|
||||
self.assertEqual(3, len(config.sites[0].interactions.routes))
|
||||
self.assertEqual(3, len(manifest["fixtures"]))
|
||||
by_path = {fixture.get("route", {}).get("fixture_path", "/"): fixture for fixture in manifest["fixtures"]}
|
||||
self.assertEqual("http://game.onion/play/", by_path["/play/"]["url"])
|
||||
self.assertEqual("interactive-lite", by_path["/play/"]["interaction"]["tier"])
|
||||
self.assertEqual("http://game.onion/checkout/", by_path["/checkout/"]["url"])
|
||||
self.assertEqual("transactional", by_path["/checkout/"]["interaction"]["tier"])
|
||||
self.assertEqual("server-invoice", by_path["/checkout/"]["interaction"]["payments"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user