mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Import app route manifests
This commit is contained in:
@@ -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
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user