Expand fixture manifests with route policies

This commit is contained in:
jwinterm
2026-07-06 03:41:04 -04:00
parent 5bfdd65dbc
commit cf0b89b572
6 changed files with 193 additions and 15 deletions
+49
View File
@@ -48,6 +48,23 @@ class ProtocolConfig:
options: dict[str, Any] = field(default_factory=dict) 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) @dataclass(frozen=True)
class SiteConfig: class SiteConfig:
id: str id: str
@@ -55,6 +72,7 @@ class SiteConfig:
source: SourceConfig source: SourceConfig
outputs: OutputConfig outputs: OutputConfig
protocols: dict[str, ProtocolConfig] protocols: dict[str, ProtocolConfig]
interactions: InteractionConfig
@dataclass(frozen=True) @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 {} source = raw_site.get("source") or {}
outputs = raw_site.get("outputs") or {} outputs = raw_site.get("outputs") or {}
protocols = raw_site.get("protocols") or {} protocols = raw_site.get("protocols") or {}
interactions = raw_site.get("interactions") or {}
site_id = _required_str(raw_site, "id") site_id = _required_str(raw_site, "id")
domain = _required_str(raw_site, "domain") 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), outputs=OutputConfig(root=output_root, plan_root=plan_root),
protocols=parsed_protocols, 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: def _resolve_path(base_dir: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser() path = Path(raw_path).expanduser()
if not path.is_absolute(): if not path.is_absolute():
+67 -12
View File
@@ -5,7 +5,7 @@ import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .config import GatewayConfig, ProtocolConfig, SiteConfig from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig
SCHEMA = "ampg.fixture-manifest.v1" SCHEMA = "ampg.fixture-manifest.v1"
@@ -19,11 +19,16 @@ class ManifestWriteResult:
def fixture_manifest(site: SiteConfig) -> dict[str, Any]: def fixture_manifest(site: SiteConfig) -> dict[str, Any]:
fixtures = [ fixtures = []
_fixture_entry(site, protocol) for protocol in site.protocols.values():
for protocol in site.protocols.values() if not protocol.enabled:
if 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 { return {
"schema": SCHEMA, "schema": SCHEMA,
"site": { "site": {
@@ -59,21 +64,31 @@ def manifest_path(site: SiteConfig) -> Path:
return site.outputs.root / "ampg-fixture-manifest.json" 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) url, address_status = _fixture_url(site, protocol)
transport = _transport_for(protocol.name) transport = _transport_for(protocol.name)
return { entry = {
"protocol": protocol.name, "protocol": protocol.name,
"renderer": protocol.renderer, "renderer": protocol.renderer,
"url": url, "url": _url_for_route(url, route_policy),
"address_status": address_status, "address_status": address_status,
"output_path": protocol.name, "output_path": _output_path_for_route(protocol.name, route_policy),
"checks": { "checks": {
"transport": transport, "transport": transport,
"profile": 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: def _transport_for(protocol_name: str) -> str:
@@ -82,7 +97,22 @@ def _transport_for(protocol_name: str) -> str:
return protocol_name 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 { return {
"tier": str(protocol.options.get("tier", protocol.options.get("max_tier", "static"))), "tier": str(protocol.options.get("tier", protocol.options.get("max_tier", "static"))),
"identity": str(protocol.options.get("identity", "none")), "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]: def _fixture_url(site: SiteConfig, protocol: ProtocolConfig) -> tuple[str, str]:
configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url") configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url")
if configured_url: if configured_url:
+5
View File
@@ -37,6 +37,11 @@ tier = "transactional"
identity = "http-session" identity = "http-session"
payments = "server-invoice" payments = "server-invoice"
[[site.interactions.route]]
match = "/admin/*"
tier = "internal"
public_allowed = false
[site.protocols.clearnet] [site.protocols.clearnet]
enabled = true enabled = true
renderer = "clearnet" renderer = "clearnet"
+5
View File
@@ -22,6 +22,7 @@ The manifest contains public route expectations only:
- expected AMPB transport and profile. - expected AMPB transport and profile.
- declared interaction tier, identity adapter, payment adapter, realtime flag, and - declared interaction tier, identity adapter, payment adapter, realtime flag, and
public exposure flag. 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, It must not contain private host inventory, hidden-service keys, tunnel keys, credentials,
or deployment notes. 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 Route-group overrides will be added separately. Until then, protocol-level options can
declare the fixture-level policy that AMPB should check. 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.
+4
View File
@@ -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 Adapters do not bypass the application. They translate constrained protocol input into the
application's public action surface, then render safe responses. 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 ## Protocol Mapping
| Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum | | Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum |
+60
View File
@@ -129,6 +129,66 @@ payments = "server-invoice"
manifest["fixtures"][0]["interaction"], 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__": if __name__ == "__main__":
unittest.main() unittest.main()