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)
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user