Import app route manifests

This commit is contained in:
jwinterm
2026-07-06 03:49:34 -04:00
parent cf0b89b572
commit 2ca73ca431
9 changed files with 315 additions and 25 deletions
+10 -7
View File
@@ -3,7 +3,8 @@
> Status: draft | Updated 2026-07-05 | Applies to: AMPG contributors and operators > 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 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 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 `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: interaction tier it wants AMPG to expose:
- `static`: rendered files only. - `static`: rendered files only.
- `forms`: server-rendered GET/POST actions with full-page responses. - `interactive-lite`: deterministic games, status lookups, quote forms, and other flows
- `sessions`: authenticated account flows with HTTP-only cookies or transport-native identity. without account state or payment confirmation.
- `realtime`: rich browser/admin surfaces; HTTP transports only. - `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. - `internal`: workers, webhooks, private admin APIs; never published automatically.
Constrained transports receive the safest compatible version. A storefront can have a Gemini Constrained transports receive the safest compatible version. A storefront can have a
catalog and inquiry flow without exposing its full JavaScript checkout. A worker API remains Gemini catalog and inquiry flow without exposing its full JavaScript checkout. A worker
internal even when the public site is available on Tor or I2P. API remains internal even when the public site is available on Tor or I2P.
## Documentation ## Documentation
+79 -16
View File
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import tomllib import tomllib
ROUTE_MANIFEST_SCHEMA = "ampg.route-manifest.v1"
DEFAULT_DAEMONS = { DEFAULT_DAEMONS = {
"clearnet": "nginx", "clearnet": "nginx",
"tor": "tor", "tor": "tor",
@@ -63,6 +66,7 @@ class InteractionConfig:
default_tier: str = "static" default_tier: str = "static"
deny_routes: tuple[str, ...] = () deny_routes: tuple[str, ...] = ()
routes: tuple[RoutePolicyConfig, ...] = () routes: tuple[RoutePolicyConfig, ...] = ()
route_manifest: Path | None = None
@dataclass(frozen=True) @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), outputs=OutputConfig(root=output_root, plan_root=plan_root),
protocols=parsed_protocols, 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: def _parse_interactions(
raw_routes = raw_interactions.get("route", ()) raw_interactions: dict[str, Any],
if isinstance(raw_routes, dict): base_dir: Path,
raw_routes = (raw_routes,) site_id: str,
if not isinstance(raw_routes, (list, tuple)): ) -> InteractionConfig:
raise ValueError("site.interactions.route must be a table array") if not isinstance(raw_interactions, dict):
deny_routes = raw_interactions.get("deny_routes", ()) raise ValueError("site.interactions must be a table")
if not isinstance(deny_routes, (list, tuple)):
raise ValueError("site.interactions.deny_routes must be an array") 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( return InteractionConfig(
default_tier=str(raw_interactions.get("default_tier", "static")), default_tier=default_tier,
deny_routes=tuple(str(route) for route in deny_routes), deny_routes=tuple(deny_routes),
routes=tuple(_parse_route_policy(raw_route) for raw_route in raw_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): 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( return RoutePolicyConfig(
match=_required_str(raw_route, "match"), 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")), identity=str(raw_route.get("identity", "none")),
payments=str(raw_route.get("payments", "none")), payments=str(raw_route.get("payments", "none")),
realtime=bool(raw_route.get("realtime", False)), 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: 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():
+24
View File
@@ -91,6 +91,14 @@ CONFIG_FIELDS = (
"[]", "[]",
"Route patterns that must not be published.", "Route patterns that must not be published.",
), ),
ConfigField(
"site.interactions",
"route_manifest",
"path",
False,
"",
"JSON route manifest generated or maintained by the application.",
),
ConfigField( ConfigField(
"site.interactions.route", "site.interactions.route",
"match", "match",
@@ -123,6 +131,22 @@ CONFIG_FIELDS = (
"none", "none",
"Payment adapter required by a route group.", "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( ConfigField(
"site.protocols.<name>", "site.protocols.<name>",
"enabled", "enabled",
+33
View File
@@ -163,6 +163,7 @@ openapi = "http://127.0.0.1:9000/openapi.json"
[site.interactions] [site.interactions]
default_tier = "interactive-lite" default_tier = "interactive-lite"
route_manifest = "./routes.json"
session_policy = "http-only-cookie" session_policy = "http-only-cookie"
deny_routes = ["/admin/*", "/api/internal/*", "/webhooks/*"] 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 `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. 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 ## Protocol-only deployments
Enabled protocols are independent. A config can omit clearnet entirely and publish only Enabled protocols are independent. A config can omit clearnet entirely and publish only
+4 -2
View File
@@ -57,9 +57,11 @@ Fixture interaction policy defaults to:
} }
``` ```
Route-group overrides will be added separately. Until then, protocol-level options can Protocol-level options can declare the base fixture 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 When `[[site.interactions.route]]` entries are configured, AMPG emits additional fixtures
for public route groups. Routes with `tier = "internal"` or `public_allowed = false` are for public route groups. Routes with `tier = "internal"` or `public_allowed = false` are
not emitted. not emitted.
Routes imported from `site.interactions.route_manifest` are treated the same way as inline
TOML routes.
+3
View File
@@ -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.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 | `default_tier` | enum | no | static | Default interaction tier for routes. |
| site.interactions | `deny_routes` | array<string> | no | [] | Route patterns that must not be published. | | site.interactions | `deny_routes` | array<string> | 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 | `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 | `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 | `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 | `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.<name> | `enabled` | boolean | no | false | Whether this protocol target is built and planned. | | site.protocols.<name> | `enabled` | boolean | no | false | Whether this protocol target is built and planned. |
| site.protocols.<name> | `renderer` | enum | no | <protocol> | Renderer profile to use for this protocol. | | site.protocols.<name> | `renderer` | enum | no | <protocol> | Renderer profile to use for this protocol. |
| site.protocols.<name> | `daemon` | enum | no | protocol default | Ingress daemon adapter. | | site.protocols.<name> | `daemon` | enum | no | protocol default | Ingress daemon adapter. |
+17
View File
@@ -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 route and interaction policy for each selected transport. Internal route groups are not
emitted. 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 ## Protocol Mapping
| Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum | | Tier | Clearnet | Tor/I2P | Gemini | IPFS | Reticulum |
+31
View File
@@ -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
}
]
}
+114
View File
@@ -189,6 +189,120 @@ renderer = "privacy-html"
self.assertEqual("transactional", by_path["/checkout/"]["interaction"]["tier"]) self.assertEqual("transactional", by_path["/checkout/"]["interaction"]["tier"])
self.assertEqual("server-invoice", by_path["/checkout/"]["interaction"]["payments"]) 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("<h1>App</h1>", 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("<h1>App</h1>", 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__": if __name__ == "__main__":
unittest.main() unittest.main()