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
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
+79 -16
View File
@@ -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():
+24
View File
@@ -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.<name>",
"enabled",
+33
View File
@@ -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
+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
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.
+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.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 | `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.<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> | `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
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 |
+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("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__":
unittest.main()