diff --git a/ampg/manifest.py b/ampg/manifest.py index ac3cefa..1c07f0a 100644 --- a/ampg/manifest.py +++ b/ampg/manifest.py @@ -9,6 +9,22 @@ from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig SCHEMA = "ampg.fixture-manifest.v1" +TIER_ORDER = { + "static": 0, + "interactive-lite": 1, + "identity": 2, + "transactional": 3, + "realtime": 4, + "internal": 5, +} +DEFAULT_MAX_TIERS = { + "clearnet": "realtime", + "tor": "transactional", + "i2p": "transactional", + "gemini": "interactive-lite", + "ipfs": "static", + "reticulum": "interactive-lite", +} @dataclass(frozen=True) @@ -27,7 +43,7 @@ def fixture_manifest(site: SiteConfig) -> dict[str, Any]: fixtures.extend( _fixture_entry(site, protocol, route_policy) for route_policy in site.interactions.routes - if _route_is_public(route_policy) + if _route_is_public(route_policy) and _route_supported(protocol, route_policy) ) return { "schema": SCHEMA, @@ -101,6 +117,18 @@ def _route_is_public(route_policy: RoutePolicyConfig) -> bool: return route_policy.public_allowed and route_policy.tier != "internal" +def _route_supported(protocol: ProtocolConfig, route_policy: RoutePolicyConfig) -> bool: + max_tier = str(protocol.options.get("max_tier", DEFAULT_MAX_TIERS.get(protocol.name, "static"))) + return _tier_rank(route_policy.tier) <= _tier_rank(max_tier) + + +def _tier_rank(tier: str) -> int: + try: + return TIER_ORDER[tier] + except KeyError as exc: + raise ValueError(f"unsupported interaction tier {tier!r}") from exc + + def _interaction_policy( protocol: ProtocolConfig, route_policy: RoutePolicyConfig | None, diff --git a/ampg/metadata.py b/ampg/metadata.py index 6b5694b..c597519 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -195,6 +195,14 @@ CONFIG_FIELDS = ( "strip", "Script handling policy for privacy-html render targets.", ), + ConfigField( + "site.protocols.", + "max_tier", + "enum", + False, + "transport default", + "Maximum interaction tier this protocol target may expose.", + ), ) diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index 82f3b4a..5ba43a5 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -65,3 +65,7 @@ not emitted. Routes imported from `site.interactions.route_manifest` are treated the same way as inline TOML routes. + +AMPG also omits route fixtures whose interaction tier exceeds the protocol target's +`max_tier`. When `max_tier` is not configured, AMPG uses the transport capability defaults +from the generated interaction capability matrix. diff --git a/docs/generated/config-schema.md b/docs/generated/config-schema.md index 3dd8469..c7d142a 100644 --- a/docs/generated/config-schema.md +++ b/docs/generated/config-schema.md @@ -28,3 +28,4 @@ This file is generated from code. Do not edit it by hand. | site.protocols. | `daemon_policy` | enum | no | protocol default | Whether AMPG adopts, manages, or only renders config. | | site.protocols. | `max_asset_bytes` | integer | no | 1048576 | Maximum asset size copied by privacy-html render targets. | | site.protocols. | `script_policy` | enum | no | strip | Script handling policy for privacy-html render targets. | +| site.protocols. | `max_tier` | enum | no | transport default | Maximum interaction tier this protocol target may expose. | diff --git a/tests/test_fixture_manifest.py b/tests/test_fixture_manifest.py index 4670ade..dc8a8cf 100644 --- a/tests/test_fixture_manifest.py +++ b/tests/test_fixture_manifest.py @@ -303,6 +303,70 @@ renderer = "privacy-html" with self.assertRaisesRegex(ValueError, "route manifest schema"): load_config(config_path) + def test_manifest_omits_routes_above_protocol_max_tier(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

App

", encoding="utf-8") + config_path = root / "gateway.toml" + config_path.write_text( + """ +[[site]] +id = "app" +domain = "app.test" + +[site.source] +kind = "static-html" +path = "./site" + +[site.outputs] +root = "./out" +plan_root = "./plan" + +[[site.interactions.route]] +match = "/play/*" +tier = "interactive-lite" + +[[site.interactions.route]] +match = "/checkout/*" +tier = "transactional" + +[[site.interactions.route]] +match = "/live/*" +tier = "realtime" +realtime = true + +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +max_tier = "interactive-lite" + +[site.protocols.gemini] +enabled = true +renderer = "gemtext" + +[site.protocols.ipfs] +enabled = true +renderer = "clearnet" +cid = "bafyexample" +""", + encoding="utf-8", + ) + + config = load_config(config_path) + manifest = fixture_manifest(config.sites[0]) + + by_protocol = {} + for fixture in manifest["fixtures"]: + by_protocol.setdefault(fixture["protocol"], []).append( + fixture.get("route", {}).get("fixture_path", "/") + ) + + self.assertEqual(["/", "/play/"], by_protocol["tor"]) + self.assertEqual(["/", "/play/"], by_protocol["gemini"]) + self.assertEqual(["/"], by_protocol["ipfs"]) + if __name__ == "__main__": unittest.main()