From 2ca73ca431e8bf42ed93ce184d6b2208905fdb98 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 03:49:34 -0400 Subject: [PATCH] Import app route manifests --- README.md | 17 +++-- ampg/config.py | 95 +++++++++++++++++++++----- ampg/metadata.py | 24 +++++++ docs/configuration.md | 33 +++++++++ docs/fixture-manifests.md | 6 +- docs/generated/config-schema.md | 3 + docs/interactive-apps.md | 17 +++++ examples/route-manifest.json | 31 +++++++++ tests/test_fixture_manifest.py | 114 ++++++++++++++++++++++++++++++++ 9 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 examples/route-manifest.json diff --git a/README.md b/README.md index e7b7a3c..95cc281 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ > Status: draft | Updated 2026-07-05 | Applies to: AMPG contributors and operators Adaptive Multi-Protocol Gateway, or AMPG, turns one canonical site into transport-appropriate -published versions for clearnet web, Tor onion services, I2P, Gemini, and Reticulum. +published versions for clearnet web, Tor onion services, I2P, Gemini, IPFS, and +Reticulum. The first use case is practical: start from an existing clearnet static site such as `wownero.org`, keep the rich web version intact, and generate privacy-hardened or @@ -64,14 +65,16 @@ AMPG is static-first, but not static-only. Each site or route group declares the interaction tier it wants AMPG to expose: - `static`: rendered files only. -- `forms`: server-rendered GET/POST actions with full-page responses. -- `sessions`: authenticated account flows with HTTP-only cookies or transport-native identity. -- `realtime`: rich browser/admin surfaces; HTTP transports only. +- `interactive-lite`: deterministic games, status lookups, quote forms, and other flows + without account state or payment confirmation. +- `identity`: authenticated sessions, wallet sign-in, or signed capability links. +- `transactional`: server-confirmed orders, deposits, invoices, donations, or payment intents. +- `realtime`: multiplayer, dashboards, websockets, streaming updates, or fast shared state. - `internal`: workers, webhooks, private admin APIs; never published automatically. -Constrained transports receive the safest compatible version. A storefront can have a Gemini -catalog and inquiry flow without exposing its full JavaScript checkout. A worker API remains -internal even when the public site is available on Tor or I2P. +Constrained transports receive the safest compatible version. A storefront can have a +Gemini catalog and inquiry flow without exposing its full JavaScript checkout. A worker +API remains internal even when the public site is available on Tor or I2P. ## Documentation diff --git a/ampg/config.py b/ampg/config.py index 7461fd0..2c8264b 100644 --- a/ampg/config.py +++ b/ampg/config.py @@ -1,11 +1,14 @@ from __future__ import annotations from dataclasses import dataclass, field +import json from pathlib import Path from typing import Any import tomllib +ROUTE_MANIFEST_SCHEMA = "ampg.route-manifest.v1" + DEFAULT_DAEMONS = { "clearnet": "nginx", "tor": "tor", @@ -63,6 +66,7 @@ class InteractionConfig: default_tier: str = "static" deny_routes: tuple[str, ...] = () routes: tuple[RoutePolicyConfig, ...] = () + route_manifest: Path | None = None @dataclass(frozen=True) @@ -122,7 +126,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), + interactions=_parse_interactions(interactions, base_dir, site_id), ) @@ -144,28 +148,44 @@ 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") +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) 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) + for raw_route in _route_array(manifest_data.get("routes", ()), "route manifest routes") + ) + routes += tuple( + _parse_route_policy(raw_route, default_tier) + for raw_route in _route_array(raw_interactions.get("route", ()), "site.interactions.route") + ) 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), + default_tier=default_tier, + deny_routes=tuple(deny_routes), + routes=routes, + route_manifest=route_manifest, ) -def _parse_route_policy(raw_route: dict[str, Any]) -> RoutePolicyConfig: +def _parse_route_policy(raw_route: dict[str, Any], default_tier: str) -> RoutePolicyConfig: if not isinstance(raw_route, dict): - raise ValueError("site.interactions.route entries must be tables") + raise ValueError("route policy entries must be objects") return RoutePolicyConfig( match=_required_str(raw_route, "match"), - tier=str(raw_route.get("tier", "static")), + 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)), @@ -173,6 +193,49 @@ def _parse_route_policy(raw_route: dict[str, Any]) -> RoutePolicyConfig: ) +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 _load_route_manifest(path: Path, site_id: str) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ValueError(f"{site_id}: route manifest not found: {path}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"{site_id}: invalid route manifest JSON: {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValueError(f"{site_id}: route manifest must be a JSON object: {path}") + if data.get("schema") != ROUTE_MANIFEST_SCHEMA: + raise ValueError( + f"{site_id}: route manifest schema must be {ROUTE_MANIFEST_SCHEMA!r}: {path}" + ) + return data + + +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(): diff --git a/ampg/metadata.py b/ampg/metadata.py index 864d9c6..6b5694b 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -91,6 +91,14 @@ CONFIG_FIELDS = ( "[]", "Route patterns that must not be published.", ), + ConfigField( + "site.interactions", + "route_manifest", + "path", + False, + "", + "JSON route manifest generated or maintained by the application.", + ), ConfigField( "site.interactions.route", "match", @@ -123,6 +131,22 @@ CONFIG_FIELDS = ( "none", "Payment adapter required by a route group.", ), + ConfigField( + "site.interactions.route", + "realtime", + "boolean", + False, + "false", + "Whether a route group requires live state or streaming updates.", + ), + ConfigField( + "site.interactions.route", + "public_allowed", + "boolean", + False, + "true", + "Whether this route group may be emitted into public protocol outputs.", + ), ConfigField( "site.protocols.", "enabled", diff --git a/docs/configuration.md b/docs/configuration.md index 8a68454..81c5702 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -163,6 +163,7 @@ openapi = "http://127.0.0.1:9000/openapi.json" [site.interactions] default_tier = "interactive-lite" +route_manifest = "./routes.json" session_policy = "http-only-cookie" deny_routes = ["/admin/*", "/api/internal/*", "/webhooks/*"] @@ -196,6 +197,38 @@ max_tier = "interactive-lite" `max_tier` is a hard cap. If a route needs a higher tier than the protocol allows, AMPG omits it or renders a safe alternate page. +## Route manifests + +Applications can generate a JSON route manifest instead of hand-maintaining every route +group in TOML. Paths are resolved relative to `gateway.toml`; inline TOML routes are +appended after manifest routes. + +```json +{ + "schema": "ampg.route-manifest.v1", + "default_tier": "interactive-lite", + "deny_routes": ["/admin/*", "/api/internal/*", "/webhooks/*"], + "routes": [ + {"match": "/play/*"}, + { + "match": "/checkout/*", + "tier": "transactional", + "identity": "http-session", + "payments": "server-invoice" + }, + { + "match": "/webhooks/*", + "tier": "internal", + "public_allowed": false + } + ] +} +``` + +Route entries use the same fields as `[[site.interactions.route]]`. The manifest should +describe public intent only; credentials, keys, deployment notes, and private hostnames do +not belong in it. + ## Protocol-only deployments Enabled protocols are independent. A config can omit clearnet entirely and publish only diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index b0ed245..82f3b4a 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -57,9 +57,11 @@ 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. +Protocol-level options can declare the base fixture 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. + +Routes imported from `site.interactions.route_manifest` are treated the same way as inline +TOML routes. diff --git a/docs/generated/config-schema.md b/docs/generated/config-schema.md index 9863fbf..3dd8469 100644 --- a/docs/generated/config-schema.md +++ b/docs/generated/config-schema.md @@ -15,10 +15,13 @@ This file is generated from code. Do not edit it by hand. | site.outputs | `plan_root` | path | no | ../dist/ampg-plan | Generated plan artifact root. | | site.interactions | `default_tier` | enum | no | static | Default interaction tier for routes. | | site.interactions | `deny_routes` | array | no | [] | Route patterns that must not be published. | +| site.interactions | `route_manifest` | path | no | - | JSON route manifest generated or maintained by the application. | | site.interactions.route | `match` | glob | no | - | Route pattern for an explicit interaction policy. | | site.interactions.route | `tier` | enum | no | site.interactions.default_tier | Interaction tier for a route group. | | site.interactions.route | `identity` | enum | no | none | Identity adapter required by a route group. | | site.interactions.route | `payments` | enum | no | none | Payment adapter required by a route group. | +| site.interactions.route | `realtime` | boolean | no | false | Whether a route group requires live state or streaming updates. | +| site.interactions.route | `public_allowed` | boolean | no | true | Whether this route group may be emitted into public protocol outputs. | | site.protocols. | `enabled` | boolean | no | false | Whether this protocol target is built and planned. | | site.protocols. | `renderer` | enum | no | | Renderer profile to use for this protocol. | | site.protocols. | `daemon` | enum | no | protocol default | Ingress daemon adapter. | diff --git a/docs/interactive-apps.md b/docs/interactive-apps.md index 06a593d..eb95217 100644 --- a/docs/interactive-apps.md +++ b/docs/interactive-apps.md @@ -49,6 +49,23 @@ Configured public route groups are emitted into fixture manifests so AMPB can ve route and interaction policy for each selected transport. Internal route groups are not emitted. +## Route Manifest Contract + +Apps may provide `ampg.route-manifest.v1` JSON. AMPG imports it from +`site.interactions.route_manifest`, merges its denylist with inline TOML, and appends any +inline TOML route entries after the imported route entries. + +Required top-level fields: + +- `schema`: must be `ampg.route-manifest.v1`. +- `routes`: route policy objects using `match`, `tier`, `identity`, `payments`, + `realtime`, and `public_allowed`. + +Optional top-level fields: + +- `default_tier`: inherited by route entries without a `tier`. +- `deny_routes`: route patterns that must not be published. + ## Protocol Mapping | Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum | diff --git a/examples/route-manifest.json b/examples/route-manifest.json new file mode 100644 index 0000000..9a9f752 --- /dev/null +++ b/examples/route-manifest.json @@ -0,0 +1,31 @@ +{ + "schema": "ampg.route-manifest.v1", + "default_tier": "interactive-lite", + "deny_routes": [ + "/admin/*", + "/api/internal/*", + "/webhooks/*" + ], + "routes": [ + { + "match": "/play/*" + }, + { + "match": "/checkout/*", + "tier": "transactional", + "identity": "http-session", + "payments": "server-invoice" + }, + { + "match": "/live/*", + "tier": "realtime", + "identity": "http-session", + "realtime": true + }, + { + "match": "/admin/*", + "tier": "internal", + "public_allowed": false + } + ] +} diff --git a/tests/test_fixture_manifest.py b/tests/test_fixture_manifest.py index 6aed0f1..4670ade 100644 --- a/tests/test_fixture_manifest.py +++ b/tests/test_fixture_manifest.py @@ -189,6 +189,120 @@ renderer = "privacy-html" self.assertEqual("transactional", by_path["/checkout/"]["interaction"]["tier"]) self.assertEqual("server-invoice", by_path["/checkout/"]["interaction"]["payments"]) + def test_manifest_imports_app_route_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

App

", encoding="utf-8") + (root / "routes.json").write_text( + json.dumps( + { + "schema": "ampg.route-manifest.v1", + "default_tier": "interactive-lite", + "deny_routes": ["/admin/*"], + "routes": [ + {"match": "/play/*"}, + { + "match": "/checkout/*", + "tier": "transactional", + "identity": "http-session", + "payments": "server-invoice", + }, + { + "match": "/webhooks/*", + "tier": "internal", + "public_allowed": False, + }, + ], + }, + indent=2, + ), + 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_manifest = "./routes.json" +deny_routes = ["/api/internal/*"] + +[[site.interactions.route]] +match = "/account/*" +tier = "identity" +identity = "http-session" + +[site.protocols.i2p] +enabled = true +renderer = "privacy-html" +""", + encoding="utf-8", + ) + + config = load_config(config_path) + manifest = fixture_manifest(config.sites[0]) + + site = config.sites[0] + self.assertEqual("interactive-lite", site.interactions.default_tier) + self.assertEqual(("/admin/*", "/api/internal/*"), site.interactions.deny_routes) + self.assertEqual(4, len(site.interactions.routes)) + self.assertEqual(4, len(manifest["fixtures"])) + + by_path = {fixture.get("route", {}).get("fixture_path", "/"): fixture for fixture in manifest["fixtures"]} + self.assertEqual("interactive-lite", by_path["/play/"]["interaction"]["tier"]) + self.assertEqual("http://app.i2p/play/", by_path["/play/"]["url"]) + self.assertEqual("transactional", by_path["/checkout/"]["interaction"]["tier"]) + self.assertEqual("server-invoice", by_path["/checkout/"]["interaction"]["payments"]) + self.assertEqual("identity", by_path["/account/"]["interaction"]["tier"]) + self.assertNotIn("/webhooks/", by_path) + + def test_route_manifest_schema_is_required(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "site" + source.mkdir() + (source / "index.html").write_text("

App

", encoding="utf-8") + (root / "routes.json").write_text('{"routes": []}', 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_manifest = "./routes.json" + +[site.protocols.tor] +enabled = true +renderer = "privacy-html" +""", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "route manifest schema"): + load_config(config_path) + if __name__ == "__main__": unittest.main()