mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Initial AMPG spec and Wownero prototype
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/.ampg/
|
||||
/dist/
|
||||
/docs/private/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,113 @@
|
||||
# Adaptive Multi-Protocol Gateway
|
||||
|
||||
> 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.
|
||||
|
||||
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
|
||||
low-bandwidth variants with sensible defaults.
|
||||
|
||||
## Goals
|
||||
|
||||
- Start from real sites, including static HTML/CSS/JS trees, not only new Markdown projects.
|
||||
- Produce protocol-specific builds from one source graph.
|
||||
- Strip active browser behavior by default on anonymity networks.
|
||||
- Manage selected protocol daemons when no suitable daemon is already running.
|
||||
- Stay operator-friendly: every generated config can be inspected before it is applied.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
- Full dynamic SaaS hosting across every transport.
|
||||
- Replacing Tor, i2pd, Caddy, Agate, or Reticulum.
|
||||
- Managing blockchain daemons as part of the core gateway.
|
||||
- Guaranteeing feature parity between rich web pages and constrained transports.
|
||||
- Exposing private admin, worker, webhook, or payment-internal surfaces by default.
|
||||
|
||||
## Operating model
|
||||
|
||||
AMPG has two responsibilities:
|
||||
|
||||
1. Build transport-specific site outputs.
|
||||
2. Connect those outputs to selected protocol ingress points.
|
||||
|
||||
For each enabled protocol, AMPG follows a daemon policy:
|
||||
|
||||
- `external`: render files and config snippets only.
|
||||
- `adopt`: use an existing daemon, and fail if none is healthy.
|
||||
- `manage`: create and supervise an AMPG-owned daemon instance.
|
||||
- `auto`: adopt an existing healthy daemon when possible; otherwise manage one.
|
||||
|
||||
The default for selected non-clearnet protocols is `auto`.
|
||||
|
||||
## Target workflow
|
||||
|
||||
```sh
|
||||
ampg init site wownero \
|
||||
--domain wownero.org \
|
||||
--source ../wownero.org-website \
|
||||
--source-kind static-html
|
||||
|
||||
ampg enable wownero tor gemini i2p reticulum
|
||||
ampg plan
|
||||
ampg build
|
||||
ampg apply
|
||||
```
|
||||
|
||||
`plan` prints rendered output paths, daemon decisions, port bindings, config diffs, and
|
||||
operator actions. `apply` performs only the approved changes.
|
||||
|
||||
## Interaction tiers
|
||||
|
||||
AMPG is static-first, but not static-only. Each site or route group declares the highest
|
||||
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.
|
||||
- `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.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Daemon management](docs/daemon-management.md)
|
||||
- [Interactive applications](docs/interactive-apps.md)
|
||||
- [Wownero integration](docs/wownero-integration.md)
|
||||
- [Autodocs and drift gates](docs/autodocs.md)
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
python3 tools/docs_check.py
|
||||
python3 -m ampg docs generate --check
|
||||
```
|
||||
|
||||
## Local Commands
|
||||
|
||||
The current implementation is dependency-free Python:
|
||||
|
||||
```sh
|
||||
python3 -m ampg --config examples/wownero.gateway.toml plan
|
||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
||||
python3 -m ampg docs generate
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Wownero build writes generated files under `dist/wownero/`. Each protocol output
|
||||
root contains `.ampg-output`; AMPG refuses to clean a non-empty output directory unless
|
||||
that marker is present.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Pick source kind: `static-html`, `markdown`, or `ssg-output`.
|
||||
- [ ] Select protocols and daemon policy.
|
||||
- [ ] Run `ampg plan`; review output roots, ports, and config changes.
|
||||
- [ ] Run `ampg build`; inspect generated variants.
|
||||
- [ ] Run `ampg apply`; publish through adopted or managed daemons.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Adaptive Multi-Protocol Gateway."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
||||
from .renderers import PrivacyRenderStats, render_privacy_html
|
||||
|
||||
|
||||
IGNORED_DIRS = {".git", ".hg", ".svn", ".vscode", "__pycache__"}
|
||||
IGNORED_SUFFIXES = (".bak", ".tmp", ".swp")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildResult:
|
||||
site_id: str
|
||||
protocol: str
|
||||
renderer: str
|
||||
output_root: Path
|
||||
files_written: int
|
||||
files_skipped: int = 0
|
||||
privacy_stats: PrivacyRenderStats | None = None
|
||||
|
||||
|
||||
def build_gateway(config: GatewayConfig) -> list[BuildResult]:
|
||||
results: list[BuildResult] = []
|
||||
for site in config.sites:
|
||||
_validate_source(site)
|
||||
for protocol in site.protocols.values():
|
||||
if not protocol.enabled:
|
||||
continue
|
||||
results.append(build_protocol(site, protocol))
|
||||
return results
|
||||
|
||||
|
||||
def build_protocol(site: SiteConfig, protocol: ProtocolConfig) -> BuildResult:
|
||||
output_root = site.outputs.root / protocol.name
|
||||
_prepare_output_root(output_root)
|
||||
if protocol.renderer == "clearnet":
|
||||
return _copy_clearnet(site, protocol, output_root)
|
||||
if protocol.renderer == "privacy-html":
|
||||
return _render_privacy_html_tree(site, protocol, output_root)
|
||||
raise ValueError(f"{site.id}/{protocol.name}: unsupported renderer {protocol.renderer!r}")
|
||||
|
||||
|
||||
def _copy_clearnet(site: SiteConfig, protocol: ProtocolConfig, output_root: Path) -> BuildResult:
|
||||
files_written = 0
|
||||
for source_path in _iter_source_files(site.source.path):
|
||||
target_path = output_root / source_path.relative_to(site.source.path)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_path, target_path)
|
||||
files_written += 1
|
||||
return BuildResult(
|
||||
site_id=site.id,
|
||||
protocol=protocol.name,
|
||||
renderer=protocol.renderer,
|
||||
output_root=output_root,
|
||||
files_written=files_written,
|
||||
)
|
||||
|
||||
|
||||
def _render_privacy_html_tree(
|
||||
site: SiteConfig, protocol: ProtocolConfig, output_root: Path
|
||||
) -> BuildResult:
|
||||
files_written = 0
|
||||
files_skipped = 0
|
||||
stats = PrivacyRenderStats()
|
||||
max_asset_bytes = int(protocol.options.get("max_asset_bytes", 1024 * 1024))
|
||||
for source_path in _iter_source_files(site.source.path):
|
||||
target_path = output_root / source_path.relative_to(site.source.path)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source_path.suffix.lower() in {".html", ".htm"}:
|
||||
html = source_path.read_text(encoding="utf-8", errors="replace")
|
||||
rendered, file_stats = render_privacy_html(html)
|
||||
target_path.write_text(rendered, encoding="utf-8")
|
||||
stats = stats.add(file_stats)
|
||||
elif source_path.suffix.lower() in {".js", ".map"}:
|
||||
files_skipped += 1
|
||||
continue
|
||||
elif source_path.stat().st_size > max_asset_bytes:
|
||||
files_skipped += 1
|
||||
continue
|
||||
else:
|
||||
shutil.copy2(source_path, target_path)
|
||||
files_written += 1
|
||||
return BuildResult(
|
||||
site_id=site.id,
|
||||
protocol=protocol.name,
|
||||
renderer=protocol.renderer,
|
||||
output_root=output_root,
|
||||
files_written=files_written,
|
||||
files_skipped=files_skipped,
|
||||
privacy_stats=stats,
|
||||
)
|
||||
|
||||
|
||||
def _iter_source_files(root: Path):
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel_parts = path.relative_to(root).parts
|
||||
if any(part in IGNORED_DIRS for part in rel_parts):
|
||||
continue
|
||||
if any(part.startswith(".") and part != ".well-known" for part in rel_parts):
|
||||
continue
|
||||
if path.name.endswith(IGNORED_SUFFIXES) or ".bak." in path.name:
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def _validate_source(site: SiteConfig) -> None:
|
||||
if not site.source.path.exists():
|
||||
raise FileNotFoundError(f"{site.id}: source path does not exist: {site.source.path}")
|
||||
if not site.source.path.is_dir():
|
||||
raise NotADirectoryError(f"{site.id}: source path is not a directory: {site.source.path}")
|
||||
|
||||
|
||||
def _prepare_output_root(output_root: Path) -> None:
|
||||
marker = output_root / ".ampg-output"
|
||||
if output_root.exists() and any(output_root.iterdir()) and not marker.exists():
|
||||
raise RuntimeError(
|
||||
f"refusing to clean non-empty unmarked output directory: {output_root}"
|
||||
)
|
||||
if marker.exists():
|
||||
shutil.rmtree(output_root)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("generated by AMPG\n", encoding="utf-8")
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from .build import build_gateway
|
||||
from .config import load_config
|
||||
from .docsgen import generate_docs
|
||||
from .plan import plan_gateway
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="ampg")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=Path("gateway.toml"),
|
||||
help="Path to gateway TOML config.",
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
subcommands.add_parser("plan", help="Print a dry-run plan.")
|
||||
subcommands.add_parser("build", help="Build enabled protocol outputs.")
|
||||
docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.")
|
||||
docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True)
|
||||
docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.")
|
||||
docs_generate.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Fail if generated docs are stale instead of writing them.",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.command == "docs":
|
||||
return _cmd_docs(args)
|
||||
config = load_config(args.config)
|
||||
if args.command == "plan":
|
||||
return _cmd_plan(config)
|
||||
if args.command == "build":
|
||||
return _cmd_build(config)
|
||||
except Exception as exc: # noqa: BLE001 - CLI should print concise failures.
|
||||
print(f"AMPG status=error message={exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_plan(config) -> int:
|
||||
for line in plan_gateway(config):
|
||||
print(
|
||||
"AMPG_PLAN "
|
||||
f"site={line.site_id} "
|
||||
f"protocol={line.protocol} "
|
||||
f"renderer={line.renderer} "
|
||||
f"output={line.output_root} "
|
||||
f"daemon={line.daemon} "
|
||||
f"policy={line.daemon_policy} "
|
||||
f"action=\"{line.action}\""
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_build(config) -> int:
|
||||
for result in build_gateway(config):
|
||||
extra = ""
|
||||
if result.privacy_stats is not None:
|
||||
stats = result.privacy_stats
|
||||
extra = (
|
||||
f" removed_active_tags={stats.removed_active_tags}"
|
||||
f" removed_event_handlers={stats.removed_event_handlers}"
|
||||
f" removed_inline_styles={stats.removed_inline_styles}"
|
||||
f" removed_remote_assets={stats.removed_remote_assets}"
|
||||
)
|
||||
print(
|
||||
"AMPG_BUILD "
|
||||
f"site={result.site_id} "
|
||||
f"protocol={result.protocol} "
|
||||
f"renderer={result.renderer} "
|
||||
f"output={result.output_root} "
|
||||
f"files={result.files_written}"
|
||||
f" skipped={result.files_skipped}"
|
||||
f"{extra}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_docs(args) -> int:
|
||||
if args.docs_command == "generate":
|
||||
changed = generate_docs(Path.cwd(), check=args.check)
|
||||
changed_text = ",".join(str(path) for path in changed) if changed else "-"
|
||||
mode = "check" if args.check else "write"
|
||||
print(f"AMPG_DOCS status=ok mode={mode} changed={changed_text}")
|
||||
return 0
|
||||
return 1
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import tomllib
|
||||
|
||||
|
||||
DEFAULT_DAEMONS = {
|
||||
"clearnet": "nginx",
|
||||
"tor": "tor",
|
||||
"i2p": "i2pd",
|
||||
"gemini": "agate",
|
||||
"reticulum": "rnsd",
|
||||
}
|
||||
|
||||
DEFAULT_POLICIES = {
|
||||
"clearnet": "adopt",
|
||||
"tor": "auto",
|
||||
"i2p": "auto",
|
||||
"gemini": "auto",
|
||||
"reticulum": "auto",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceConfig:
|
||||
kind: str
|
||||
path: Path
|
||||
canonical_url: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputConfig:
|
||||
root: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProtocolConfig:
|
||||
name: str
|
||||
enabled: bool
|
||||
renderer: str
|
||||
daemon: str
|
||||
daemon_policy: str
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SiteConfig:
|
||||
id: str
|
||||
domain: str
|
||||
source: SourceConfig
|
||||
outputs: OutputConfig
|
||||
protocols: dict[str, ProtocolConfig]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayConfig:
|
||||
config_path: Path
|
||||
sites: list[SiteConfig]
|
||||
|
||||
|
||||
def load_config(path: Path) -> GatewayConfig:
|
||||
config_path = path.resolve()
|
||||
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
sites = [_parse_site(raw_site, config_path.parent) for raw_site in data.get("site", [])]
|
||||
if not sites:
|
||||
raise ValueError("config must declare at least one [[site]]")
|
||||
return GatewayConfig(config_path=config_path, sites=sites)
|
||||
|
||||
|
||||
def _parse_site(raw_site: dict[str, Any], base_dir: Path) -> SiteConfig:
|
||||
source = raw_site.get("source") or {}
|
||||
outputs = raw_site.get("outputs") or {}
|
||||
protocols = raw_site.get("protocols") or {}
|
||||
|
||||
site_id = _required_str(raw_site, "id")
|
||||
domain = _required_str(raw_site, "domain")
|
||||
source_kind = _required_str(source, "kind")
|
||||
if source_kind != "static-html":
|
||||
raise ValueError(f"{site_id}: unsupported source kind {source_kind!r}")
|
||||
|
||||
source_path = _resolve_path(base_dir, _required_str(source, "path"))
|
||||
output_root = _resolve_path(base_dir, _required_str(outputs, "root"))
|
||||
|
||||
parsed_protocols = {
|
||||
name: _parse_protocol(name, raw_protocol)
|
||||
for name, raw_protocol in protocols.items()
|
||||
if isinstance(raw_protocol, dict)
|
||||
}
|
||||
|
||||
return SiteConfig(
|
||||
id=site_id,
|
||||
domain=domain,
|
||||
source=SourceConfig(
|
||||
kind=source_kind,
|
||||
path=source_path,
|
||||
canonical_url=source.get("canonical_url"),
|
||||
),
|
||||
outputs=OutputConfig(root=output_root),
|
||||
protocols=parsed_protocols,
|
||||
)
|
||||
|
||||
|
||||
def _parse_protocol(name: str, raw_protocol: dict[str, Any]) -> ProtocolConfig:
|
||||
enabled = bool(raw_protocol.get("enabled", False))
|
||||
renderer = str(raw_protocol.get("renderer", name))
|
||||
daemon = str(raw_protocol.get("daemon", DEFAULT_DAEMONS.get(name, name)))
|
||||
policy = str(raw_protocol.get("daemon_policy", DEFAULT_POLICIES.get(name, "auto")))
|
||||
options = dict(raw_protocol)
|
||||
for key in ("enabled", "renderer", "daemon", "daemon_policy"):
|
||||
options.pop(key, None)
|
||||
return ProtocolConfig(
|
||||
name=name,
|
||||
enabled=enabled,
|
||||
renderer=renderer,
|
||||
daemon=daemon,
|
||||
daemon_policy=policy,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_path(base_dir: Path, raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = base_dir / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _required_str(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"missing required string field {key!r}")
|
||||
return value
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .metadata import CONFIG_FIELDS, DAEMON_ADAPTERS, RENDER_PROFILES
|
||||
|
||||
|
||||
GENERATED_DIR = Path("docs/generated")
|
||||
|
||||
|
||||
def generate_docs(root: Path, *, check: bool = False) -> list[Path]:
|
||||
docs = {
|
||||
GENERATED_DIR / "config-schema.md": _config_schema_doc(),
|
||||
GENERATED_DIR / "daemon-adapters.md": _daemon_adapters_doc(),
|
||||
GENERATED_DIR / "render-profiles.md": _render_profiles_doc(),
|
||||
}
|
||||
changed: list[Path] = []
|
||||
for rel_path, content in docs.items():
|
||||
path = root / rel_path
|
||||
if path.exists() and path.read_text(encoding="utf-8") == content:
|
||||
continue
|
||||
changed.append(rel_path)
|
||||
if not check:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
if check and changed:
|
||||
names = ", ".join(str(path) for path in changed)
|
||||
raise RuntimeError(f"generated docs are stale: {names}")
|
||||
return changed
|
||||
|
||||
|
||||
def _header(title: str) -> str:
|
||||
return (
|
||||
f"# {title}\n\n"
|
||||
"> Status: generated | Updated by `python3 -m ampg docs generate` | Applies to: AMPG\n\n"
|
||||
"This file is generated from code. Do not edit it by hand.\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _config_schema_doc() -> str:
|
||||
rows = [
|
||||
"| Section | Field | Type | Required | Default | Description |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for field in CONFIG_FIELDS:
|
||||
rows.append(
|
||||
"| "
|
||||
f"{field.section} | "
|
||||
f"`{field.field}` | "
|
||||
f"{field.kind} | "
|
||||
f"{'yes' if field.required else 'no'} | "
|
||||
f"{field.default or '-'} | "
|
||||
f"{field.description} |"
|
||||
)
|
||||
return _header("Generated Config Schema") + "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
def _daemon_adapters_doc() -> str:
|
||||
sections = [_header("Generated Daemon Adapters")]
|
||||
for adapter in DAEMON_ADAPTERS:
|
||||
sections.append(f"## {adapter.daemon}\n")
|
||||
sections.append(f"- Protocols: {', '.join(adapter.protocols)}")
|
||||
sections.append(f"- Default policy: `{adapter.default_policy}`")
|
||||
sections.append(f"- Generated artifacts: {', '.join(adapter.generated_artifacts)}")
|
||||
sections.append("")
|
||||
sections.append("Notes:")
|
||||
for note in adapter.notes:
|
||||
sections.append(f"- {note}")
|
||||
sections.append("")
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def _render_profiles_doc() -> str:
|
||||
sections = [_header("Generated Render Profiles")]
|
||||
for profile in RENDER_PROFILES:
|
||||
sections.append(f"## {profile.name}\n")
|
||||
sections.append(profile.summary)
|
||||
sections.append("")
|
||||
sections.append(f"- Output: {profile.output}")
|
||||
sections.append("")
|
||||
sections.append("Defaults:")
|
||||
for default in profile.defaults:
|
||||
sections.append(f"- {default}")
|
||||
sections.append("")
|
||||
return "\n".join(sections)
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import DEFAULT_DAEMONS, DEFAULT_POLICIES
|
||||
from .renderers import ACTIVE_TAGS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigField:
|
||||
section: str
|
||||
field: str
|
||||
kind: str
|
||||
required: bool
|
||||
default: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderProfile:
|
||||
name: str
|
||||
summary: str
|
||||
output: str
|
||||
defaults: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DaemonAdapter:
|
||||
daemon: str
|
||||
protocols: tuple[str, ...]
|
||||
default_policy: str
|
||||
generated_artifacts: tuple[str, ...]
|
||||
notes: tuple[str, ...]
|
||||
|
||||
|
||||
CONFIG_FIELDS = (
|
||||
ConfigField("site", "id", "string", True, "", "Stable site identifier used in output paths."),
|
||||
ConfigField("site", "domain", "string", True, "", "Canonical public domain for the site."),
|
||||
ConfigField("site.source", "kind", "enum", True, "static-html", "Source adapter kind."),
|
||||
ConfigField("site.source", "path", "path", True, "", "Source tree path."),
|
||||
ConfigField("site.source", "canonical_url", "url", False, "", "Canonical clearnet URL."),
|
||||
ConfigField("site.outputs", "root", "path", True, "", "Generated output root."),
|
||||
ConfigField(
|
||||
"site.interactions",
|
||||
"default_tier",
|
||||
"enum",
|
||||
False,
|
||||
"static",
|
||||
"Default interaction tier for routes.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.interactions",
|
||||
"deny_routes",
|
||||
"array<string>",
|
||||
False,
|
||||
"[]",
|
||||
"Route patterns that must not be published.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"enabled",
|
||||
"boolean",
|
||||
False,
|
||||
"false",
|
||||
"Whether this protocol target is built and planned.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"renderer",
|
||||
"enum",
|
||||
False,
|
||||
"<protocol>",
|
||||
"Renderer profile to use for this protocol.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"daemon",
|
||||
"enum",
|
||||
False,
|
||||
"protocol default",
|
||||
"Ingress daemon adapter.",
|
||||
),
|
||||
ConfigField(
|
||||
"site.protocols.<name>",
|
||||
"daemon_policy",
|
||||
"enum",
|
||||
False,
|
||||
"protocol default",
|
||||
"Whether AMPG adopts, manages, or only renders config.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
RENDER_PROFILES = (
|
||||
RenderProfile(
|
||||
name="clearnet",
|
||||
summary="High-fidelity static web output.",
|
||||
output="HTML/CSS/JS/assets",
|
||||
defaults=(
|
||||
"Copies source files except hidden/editor/VCS clutter.",
|
||||
"Keeps local JavaScript and rich media.",
|
||||
"Writes only to AMPG-marked generated output roots.",
|
||||
),
|
||||
),
|
||||
RenderProfile(
|
||||
name="privacy-html",
|
||||
summary="Static HTML for Tor and I2P.",
|
||||
output="HTML/CSS/assets",
|
||||
defaults=(
|
||||
f"Removes active tags: {', '.join(sorted(ACTIVE_TAGS))}.",
|
||||
"Removes inline event handlers such as onclick and onload.",
|
||||
"Removes inline style attributes.",
|
||||
"Skips JavaScript and source-map files.",
|
||||
"Skips oversized assets above max_asset_bytes.",
|
||||
"Removes remote src/poster/link asset references.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
DAEMON_ADAPTERS = (
|
||||
DaemonAdapter(
|
||||
daemon="nginx",
|
||||
protocols=("clearnet", "tor", "i2p"),
|
||||
default_policy=DEFAULT_POLICIES["clearnet"],
|
||||
generated_artifacts=("server block snippets",),
|
||||
notes=(
|
||||
"Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.",
|
||||
"Tor/I2P HTTP targets can use loopback-only server blocks.",
|
||||
),
|
||||
),
|
||||
DaemonAdapter(
|
||||
daemon=DEFAULT_DAEMONS["tor"],
|
||||
protocols=("tor",),
|
||||
default_policy=DEFAULT_POLICIES["tor"],
|
||||
generated_artifacts=("torrc hidden-service snippet",),
|
||||
notes=(
|
||||
"AMPG should preserve existing HiddenServiceDir material when adopting.",
|
||||
"Managed hidden-service keys belong under AMPG state.",
|
||||
),
|
||||
),
|
||||
DaemonAdapter(
|
||||
daemon=DEFAULT_DAEMONS["i2p"],
|
||||
protocols=("i2p",),
|
||||
default_policy=DEFAULT_POLICIES["i2p"],
|
||||
generated_artifacts=("i2pd server tunnel snippet",),
|
||||
notes=(
|
||||
"Web tunnels should use web-specific key files.",
|
||||
"Existing RPC/P2P tunnel keys are not reused for web publishing.",
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import GatewayConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanLine:
|
||||
site_id: str
|
||||
protocol: str
|
||||
renderer: str
|
||||
output_root: Path
|
||||
daemon: str
|
||||
daemon_policy: str
|
||||
action: str
|
||||
|
||||
|
||||
def plan_gateway(config: GatewayConfig) -> list[PlanLine]:
|
||||
lines: list[PlanLine] = []
|
||||
for site in config.sites:
|
||||
for protocol in site.protocols.values():
|
||||
if not protocol.enabled:
|
||||
continue
|
||||
lines.append(
|
||||
PlanLine(
|
||||
site_id=site.id,
|
||||
protocol=protocol.name,
|
||||
renderer=protocol.renderer,
|
||||
output_root=site.outputs.root / protocol.name,
|
||||
daemon=protocol.daemon,
|
||||
daemon_policy=protocol.daemon_policy,
|
||||
action=_action_for_policy(protocol.daemon_policy),
|
||||
)
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _action_for_policy(policy: str) -> str:
|
||||
if policy == "external":
|
||||
return "render outputs and config snippets only"
|
||||
if policy == "adopt":
|
||||
return "adopt existing daemon; fail if missing"
|
||||
if policy == "manage":
|
||||
return "create AMPG-owned daemon instance"
|
||||
if policy == "auto":
|
||||
return "adopt healthy daemon, otherwise manage AMPG-owned instance"
|
||||
return "unknown policy; no apply support"
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
VOID_ELEMENTS = {
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
}
|
||||
|
||||
ACTIVE_TAGS = {"script", "style"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrivacyRenderStats:
|
||||
removed_active_tags: int = 0
|
||||
removed_event_handlers: int = 0
|
||||
removed_inline_styles: int = 0
|
||||
removed_remote_assets: int = 0
|
||||
|
||||
def add(self, other: "PrivacyRenderStats") -> "PrivacyRenderStats":
|
||||
return PrivacyRenderStats(
|
||||
removed_active_tags=self.removed_active_tags + other.removed_active_tags,
|
||||
removed_event_handlers=self.removed_event_handlers + other.removed_event_handlers,
|
||||
removed_inline_styles=self.removed_inline_styles + other.removed_inline_styles,
|
||||
removed_remote_assets=self.removed_remote_assets + other.removed_remote_assets,
|
||||
)
|
||||
|
||||
|
||||
class PrivacyHtmlRenderer(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.parts: list[str] = []
|
||||
self.skip_stack: list[str] = []
|
||||
self.stats = PrivacyRenderStats()
|
||||
|
||||
def render(self, html: str) -> tuple[str, PrivacyRenderStats]:
|
||||
self.feed(html)
|
||||
self.close()
|
||||
return "".join(self.parts), self.stats
|
||||
|
||||
def handle_decl(self, decl: str) -> None:
|
||||
if not self.skip_stack:
|
||||
self.parts.append(f"<!{decl}>")
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in ACTIVE_TAGS:
|
||||
self.skip_stack.append(tag)
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags + 1,
|
||||
removed_event_handlers=self.stats.removed_event_handlers,
|
||||
removed_inline_styles=self.stats.removed_inline_styles,
|
||||
removed_remote_assets=self.stats.removed_remote_assets,
|
||||
)
|
||||
return
|
||||
if self.skip_stack:
|
||||
return
|
||||
clean_attrs = self._clean_attrs(tag, attrs)
|
||||
self.parts.append(f"<{tag}{_format_attrs(clean_attrs)}>")
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
if tag in ACTIVE_TAGS:
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags + 1,
|
||||
removed_event_handlers=self.stats.removed_event_handlers,
|
||||
removed_inline_styles=self.stats.removed_inline_styles,
|
||||
removed_remote_assets=self.stats.removed_remote_assets,
|
||||
)
|
||||
return
|
||||
if self.skip_stack:
|
||||
return
|
||||
clean_attrs = self._clean_attrs(tag, attrs)
|
||||
self.parts.append(f"<{tag}{_format_attrs(clean_attrs)}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if self.skip_stack:
|
||||
if self.skip_stack[-1] == tag:
|
||||
self.skip_stack.pop()
|
||||
return
|
||||
if tag not in VOID_ELEMENTS:
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.skip_stack:
|
||||
self.parts.append(data)
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
if not self.skip_stack:
|
||||
self.parts.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
if not self.skip_stack:
|
||||
self.parts.append(f"&#{name};")
|
||||
|
||||
def handle_comment(self, data: str) -> None:
|
||||
return
|
||||
|
||||
def _clean_attrs(
|
||||
self, tag: str, attrs: list[tuple[str, str | None]]
|
||||
) -> list[tuple[str, str | None]]:
|
||||
clean: list[tuple[str, str | None]] = []
|
||||
for name, value in attrs:
|
||||
attr = name.lower()
|
||||
if attr.startswith("on"):
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags,
|
||||
removed_event_handlers=self.stats.removed_event_handlers + 1,
|
||||
removed_inline_styles=self.stats.removed_inline_styles,
|
||||
removed_remote_assets=self.stats.removed_remote_assets,
|
||||
)
|
||||
continue
|
||||
if attr == "style":
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags,
|
||||
removed_event_handlers=self.stats.removed_event_handlers,
|
||||
removed_inline_styles=self.stats.removed_inline_styles + 1,
|
||||
removed_remote_assets=self.stats.removed_remote_assets,
|
||||
)
|
||||
continue
|
||||
if attr in {"src", "poster"} and value and _is_remote_url(value):
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags,
|
||||
removed_event_handlers=self.stats.removed_event_handlers,
|
||||
removed_inline_styles=self.stats.removed_inline_styles,
|
||||
removed_remote_assets=self.stats.removed_remote_assets + 1,
|
||||
)
|
||||
continue
|
||||
if tag == "link" and attr == "href" and value and _is_remote_url(value):
|
||||
self.stats = PrivacyRenderStats(
|
||||
removed_active_tags=self.stats.removed_active_tags,
|
||||
removed_event_handlers=self.stats.removed_event_handlers,
|
||||
removed_inline_styles=self.stats.removed_inline_styles,
|
||||
removed_remote_assets=self.stats.removed_remote_assets + 1,
|
||||
)
|
||||
continue
|
||||
clean.append((name, value))
|
||||
return clean
|
||||
|
||||
|
||||
def render_privacy_html(html: str) -> tuple[str, PrivacyRenderStats]:
|
||||
return PrivacyHtmlRenderer().render(html)
|
||||
|
||||
|
||||
def _format_attrs(attrs: list[tuple[str, str | None]]) -> str:
|
||||
if not attrs:
|
||||
return ""
|
||||
rendered = []
|
||||
for name, value in attrs:
|
||||
if value is None:
|
||||
rendered.append(escape(name, quote=True))
|
||||
else:
|
||||
rendered.append(f'{escape(name, quote=True)}="{escape(value, quote=True)}"')
|
||||
return " " + " ".join(rendered)
|
||||
|
||||
|
||||
def _is_remote_url(value: str) -> bool:
|
||||
lower = value.lower()
|
||||
return lower.startswith("http://") or lower.startswith("https://") or lower.startswith("//")
|
||||
@@ -0,0 +1,99 @@
|
||||
# Architecture
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: AMPG core
|
||||
|
||||
AMPG is a compiler plus an ingress manager. The compiler turns one site into a
|
||||
protocol-neutral content graph, then renders protocol-specific outputs. The ingress
|
||||
manager connects those outputs to daemons selected by the operator.
|
||||
|
||||
## System shape
|
||||
|
||||
```text
|
||||
source site
|
||||
-> source adapter
|
||||
-> content graph
|
||||
-> render profiles
|
||||
-> output roots
|
||||
-> daemon adapters
|
||||
-> clearnet / Tor / I2P / Gemini / Reticulum
|
||||
```
|
||||
|
||||
## Source adapters
|
||||
|
||||
`static-html`
|
||||
: Reads an existing static site tree. AMPG preserves the clearnet version, parses HTML
|
||||
into a content graph, rewrites local links, and extracts text for Gemini and Micron
|
||||
renderers. This is the default path for the Wownero pilot.
|
||||
|
||||
`markdown`
|
||||
: Reads Markdown plus frontmatter. This is the cleanest authoring model for new sites.
|
||||
|
||||
`ssg-output`
|
||||
: Reads the built output of another static site generator. AMPG does not own that
|
||||
generator; it adapts the generated files.
|
||||
|
||||
## Content graph
|
||||
|
||||
The internal graph is intentionally small:
|
||||
|
||||
- `Site`: domain, aliases, selected protocols, source metadata.
|
||||
- `Page`: route, title, language, headings, body blocks, links, assets.
|
||||
- `Asset`: path, media type, size, dimensions, privacy policy.
|
||||
- `Link`: target, relation, transport compatibility.
|
||||
|
||||
Renderers should not scrape each other. They read the content graph and write their
|
||||
own output.
|
||||
|
||||
## Render profiles
|
||||
|
||||
`clearnet`
|
||||
: High-fidelity output. Static HTML sources are copied by default. Markdown sources render
|
||||
to full HTML and may include CSS, JavaScript, web fonts, and rich media.
|
||||
|
||||
`privacy-html`
|
||||
: Tor and I2P default. Removes JavaScript, tracking pixels, external fonts, third-party
|
||||
embeds, remote assets, and stateful browser storage assumptions. CSS is local and small.
|
||||
|
||||
`gemtext`
|
||||
: Gemini output. Converts headings, paragraphs, lists, code blocks, and links to `.gmi`.
|
||||
Inline links become block links near the paragraph by default.
|
||||
|
||||
`micron`
|
||||
: Reticulum/Nomad output. Produces terminal-oriented Micron markup with small pages,
|
||||
plain links, and no heavy media by default.
|
||||
|
||||
## Daemon adapters
|
||||
|
||||
Daemon adapters provide a common interface:
|
||||
|
||||
- `detect`: find existing services, ports, config files, and health endpoints.
|
||||
- `plan`: decide whether to adopt, manage, or fail.
|
||||
- `render_config`: produce config snippets and complete managed configs.
|
||||
- `apply`: write approved managed config and start or reload services.
|
||||
- `health`: verify the selected transport can serve the generated output.
|
||||
|
||||
Adapters must be conservative. They do not modify non-AMPG config without showing a
|
||||
plan first, and managed config lives under AMPG-owned state directories.
|
||||
|
||||
## Interaction boundary
|
||||
|
||||
v1 is static-first, but the architecture reserves a path for interactive applications.
|
||||
Interactivity is declared per route group:
|
||||
|
||||
- `static`: files only.
|
||||
- `forms`: server-rendered actions and full-page responses.
|
||||
- `sessions`: authenticated flows with strict session policy.
|
||||
- `realtime`: rich browser/admin interfaces; HTTP transports only.
|
||||
- `internal`: workers, webhooks, private admin APIs; never published automatically.
|
||||
|
||||
Dynamic support comes through application adapters that expose action metadata. HTTP
|
||||
transports can use normal forms or reverse proxies. Gemini uses status-code input prompts
|
||||
for simple actions. Reticulum uses page scripts or Micron action links. The core compiler
|
||||
must remain useful when no application adapter is configured.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Add a source adapter only when it can produce the content graph.
|
||||
- [ ] Add a renderer only when it reads the graph directly.
|
||||
- [ ] Add a daemon adapter only with `detect`, `plan`, `render_config`, `apply`, and `health`.
|
||||
- [ ] Keep privacy defaults stricter than clearnet defaults.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Autodocs And Drift Gates
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: AMPG generated docs
|
||||
|
||||
AMPG should publish concise hand-written docs and generate the parts that can drift from
|
||||
code. Generated artifacts are committed, then checked in CI by regenerating and diffing.
|
||||
|
||||
## Generated artifacts
|
||||
|
||||
`docs/generated/config-schema.md`
|
||||
: Generated from the `gateway.toml` schema. Includes fields, defaults, enum values, and
|
||||
examples.
|
||||
|
||||
`docs/generated/daemon-adapters.md`
|
||||
: Generated from registered daemon adapters. Lists supported daemons, discovery paths,
|
||||
managed-state paths, health checks, and known limitations.
|
||||
|
||||
`docs/generated/render-profiles.md`
|
||||
: Generated from renderer definitions. Lists stripped tags, asset limits, link modes,
|
||||
and defaults for each target.
|
||||
|
||||
`openapi.json`
|
||||
: Generated if AMPG exposes a local control API for status, builds, plans, and daemon
|
||||
health.
|
||||
|
||||
## Drift gates
|
||||
|
||||
CI should run:
|
||||
|
||||
```sh
|
||||
python3 -m ampg docs generate
|
||||
python3 -m ampg docs generate --check
|
||||
git diff --exit-code docs/generated openapi.json
|
||||
python3 tools/docs_check.py
|
||||
```
|
||||
|
||||
`ampg docs check` should verify:
|
||||
|
||||
- every Markdown file starts with a `> Status:` header.
|
||||
- internal links resolve.
|
||||
- generated docs are not edited by hand.
|
||||
- public docs contain no private keys, onion keys, I2P keys, Reticulum identities, or
|
||||
local production secrets.
|
||||
|
||||
## Public/private boundary
|
||||
|
||||
Public docs include reusable contracts, generated references, and concise operator-facing
|
||||
guidance. Private docs are ignored under `docs/private/` and are the place for strategy,
|
||||
internal discussion, deployment inventory, working notes, tracking, timelines, and any
|
||||
real host-specific material.
|
||||
|
||||
Public docs can include example domains, ports, and fake keys. Real daemon keys,
|
||||
production paths with secrets, private host inventory, and internal planning notes belong
|
||||
outside the public repo.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Generate config schema docs from code.
|
||||
- [ ] Generate daemon adapter docs from registered adapters.
|
||||
- [ ] Generate renderer docs from renderer defaults.
|
||||
- [ ] Commit generated artifacts.
|
||||
- [ ] Fail CI when regeneration produces a diff.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Configuration
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: `gateway.toml`
|
||||
|
||||
AMPG is configured from one TOML file. The file declares global paths, sites, source
|
||||
adapters, render targets, and daemon policy.
|
||||
|
||||
## Minimal Wownero pilot config
|
||||
|
||||
```toml
|
||||
[gateway]
|
||||
state_dir = "/var/lib/ampg"
|
||||
cache_dir = "/var/cache/ampg"
|
||||
run_dir = "/run/ampg"
|
||||
user = "ampg"
|
||||
|
||||
[[site]]
|
||||
id = "wownero"
|
||||
domain = "wownero.org"
|
||||
|
||||
[site.source]
|
||||
kind = "static-html"
|
||||
path = "../wownero.org-website"
|
||||
canonical_url = "https://wownero.org"
|
||||
|
||||
[site.outputs]
|
||||
root = "/var/www/ampg/wownero"
|
||||
|
||||
[site.interactions]
|
||||
default_tier = "static"
|
||||
deny_routes = ["/admin/*", "/internal/*", "/webhooks/*"]
|
||||
|
||||
[site.protocols.clearnet]
|
||||
enabled = true
|
||||
renderer = "clearnet"
|
||||
daemon = "caddy"
|
||||
daemon_policy = "adopt"
|
||||
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
renderer = "privacy-html"
|
||||
daemon = "tor"
|
||||
daemon_policy = "auto"
|
||||
local_port = 18080
|
||||
onion_location = "auto"
|
||||
|
||||
[site.protocols.i2p]
|
||||
enabled = false
|
||||
renderer = "privacy-html"
|
||||
daemon = "i2pd"
|
||||
daemon_policy = "auto"
|
||||
local_port = 18081
|
||||
|
||||
[site.protocols.gemini]
|
||||
enabled = false
|
||||
renderer = "gemtext"
|
||||
daemon = "agate"
|
||||
daemon_policy = "auto"
|
||||
port = 1965
|
||||
|
||||
[site.protocols.reticulum]
|
||||
enabled = false
|
||||
renderer = "micron"
|
||||
daemon = "rnsd"
|
||||
daemon_policy = "auto"
|
||||
aspect = "web"
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
Selected protocols default to `daemon_policy = "auto"` unless the protocol is clearnet.
|
||||
Clearnet defaults to `adopt`, because most operators already have a web server and TLS
|
||||
workflow.
|
||||
|
||||
| Protocol | Default renderer | Default daemon | Default policy |
|
||||
| --- | --- | --- | --- |
|
||||
| clearnet | `clearnet` | `caddy` | `adopt` |
|
||||
| tor | `privacy-html` | `tor` | `auto` |
|
||||
| i2p | `privacy-html` | `i2pd` | `auto` |
|
||||
| gemini | `gemtext` | `agate` | `auto` |
|
||||
| reticulum | `micron` | `rnsd` | `auto` |
|
||||
|
||||
## Renderer defaults
|
||||
|
||||
`privacy-html` removes:
|
||||
|
||||
- `<script>` blocks and event handler attributes.
|
||||
- remote fonts, analytics, and third-party embeds.
|
||||
- localStorage/sessionStorage assumptions.
|
||||
- non-local asset references unless explicitly allowed.
|
||||
|
||||
`gemtext` defaults:
|
||||
|
||||
- one `.gmi` page per HTML or Markdown page.
|
||||
- inline links emitted after the paragraph that referenced them.
|
||||
- images and PDFs emitted as links with file size metadata when known.
|
||||
|
||||
`micron` defaults:
|
||||
|
||||
- one `.mu` page per high-value page.
|
||||
- headings converted to terminal-safe emphasis.
|
||||
- images omitted unless the site config opts into small downloadable assets.
|
||||
|
||||
## Daemon policy values
|
||||
|
||||
`external`
|
||||
: Build outputs and print config. Never inspect, write, start, or reload daemons.
|
||||
|
||||
`adopt`
|
||||
: Use a configured or detected daemon. Fail with a clear diagnostic if missing.
|
||||
|
||||
`manage`
|
||||
: Create an AMPG-owned daemon instance and supervise it.
|
||||
|
||||
`auto`
|
||||
: Adopt a healthy daemon when one is found. Otherwise create an AMPG-owned instance.
|
||||
|
||||
## Interactive app config
|
||||
|
||||
Applications can declare route groups and upstreams. AMPG only exposes the tiers each
|
||||
transport can safely support.
|
||||
|
||||
```toml
|
||||
[[site]]
|
||||
id = "shop_platform"
|
||||
domain = "example-shop.net"
|
||||
|
||||
[site.source]
|
||||
kind = "reverse-proxy"
|
||||
upstream = "http://127.0.0.1:8000"
|
||||
openapi = "http://127.0.0.1:9000/openapi.json"
|
||||
|
||||
[site.interactions]
|
||||
default_tier = "forms"
|
||||
session_policy = "http-only-cookie"
|
||||
deny_routes = ["/admin/*", "/api/internal/*", "/webhooks/*"]
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/admin/*"
|
||||
tier = "internal"
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/checkout/*"
|
||||
tier = "sessions"
|
||||
|
||||
[[site.interactions.route]]
|
||||
match = "/catalog/*"
|
||||
tier = "forms"
|
||||
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
renderer = "privacy-html"
|
||||
daemon_policy = "auto"
|
||||
allow_javascript = false
|
||||
|
||||
[site.protocols.gemini]
|
||||
enabled = true
|
||||
renderer = "gemtext"
|
||||
daemon_policy = "auto"
|
||||
max_tier = "forms"
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Keep paths explicit in production configs.
|
||||
- [ ] Use `ampg plan` before `ampg apply`.
|
||||
- [ ] Use `daemon_policy = "external"` for package-maintainer examples.
|
||||
- [ ] Use `daemon_policy = "auto"` for single-box deployments.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Daemon Management
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: protocol adapters
|
||||
|
||||
AMPG can use daemons that already exist on a server or create AMPG-owned daemons when
|
||||
the selected protocol is not running. This keeps simple deployments simple while avoiding
|
||||
surprise edits to operator-owned services.
|
||||
|
||||
## Ownership rules
|
||||
|
||||
Adopted daemons:
|
||||
|
||||
- are already installed and healthy.
|
||||
- keep their existing service manager and primary config ownership.
|
||||
- receive generated snippets only after the operator approves the plan.
|
||||
- are never rewritten wholesale by AMPG.
|
||||
|
||||
Managed daemons:
|
||||
|
||||
- store config under `state_dir`.
|
||||
- expose logs and health through AMPG.
|
||||
- use AMPG-owned ports, sockets, hidden-service dirs, key files, and output roots.
|
||||
- can be stopped, restarted, or removed by AMPG.
|
||||
|
||||
## Discovery order
|
||||
|
||||
For each enabled protocol, AMPG checks:
|
||||
|
||||
1. Explicit config in `gateway.toml`.
|
||||
2. AMPG-owned state from previous runs.
|
||||
3. Common system services and ports.
|
||||
4. Known config paths for supported daemons.
|
||||
|
||||
The result is shown in `ampg plan`.
|
||||
|
||||
## Adapter notes
|
||||
|
||||
### Clearnet
|
||||
|
||||
Default daemon: Caddy.
|
||||
|
||||
AMPG should usually adopt clearnet ingress instead of managing it. Existing TLS,
|
||||
firewall, and reverse proxy rules are operator-owned. Managed clearnet is useful for
|
||||
development and single-purpose boxes.
|
||||
|
||||
### Tor
|
||||
|
||||
Default daemon: Tor.
|
||||
|
||||
If Tor is already running, AMPG can add a hidden service snippet that points to the
|
||||
`privacy-html` local server. If Tor is missing and policy is `auto` or `manage`, AMPG
|
||||
creates an AMPG-owned Tor instance or service unit and stores hidden-service material
|
||||
under the AMPG state directory.
|
||||
|
||||
### I2P
|
||||
|
||||
Default daemon: i2pd.
|
||||
|
||||
AMPG first supports server tunnels through i2pd. I2P-Zero can be added as a second
|
||||
adapter with the same interface. Managed I2P must keep destination keys in AMPG state
|
||||
and include backup warnings in the plan.
|
||||
|
||||
### Gemini
|
||||
|
||||
Default daemon: Agate.
|
||||
|
||||
Gemini is static-friendly and can be managed safely. AMPG generates `.gmi` output,
|
||||
certificates when requested, and an Agate config pointing at the Gemini output root.
|
||||
|
||||
### Reticulum
|
||||
|
||||
Default daemon: rnsd or a compatible page server.
|
||||
|
||||
Reticulum management is split: AMPG can manage generated Micron pages and a page-serving
|
||||
process, but physical interfaces such as LoRa, serial, packet radio, or custom transports
|
||||
may require explicit operator setup. `ampg plan` must distinguish page-server readiness
|
||||
from transport-interface readiness.
|
||||
|
||||
## Safety requirements
|
||||
|
||||
- Show every config write before applying it.
|
||||
- Back up any adopted-daemon snippet AMPG touches.
|
||||
- Never delete hidden-service keys, I2P keys, or Reticulum identities without an explicit
|
||||
destructive command.
|
||||
- Refuse to bind public ports when the selected renderer is not built.
|
||||
- Health-check every enabled protocol after apply.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Adapter detects existing daemon state.
|
||||
- [ ] Adapter can produce a dry-run plan.
|
||||
- [ ] Managed config is isolated under AMPG state.
|
||||
- [ ] Key material is backed up or clearly called out.
|
||||
- [ ] Health check validates the generated site through the selected transport.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated Config Schema
|
||||
|
||||
> Status: generated | Updated by `python3 -m ampg docs generate` | Applies to: AMPG
|
||||
|
||||
This file is generated from code. Do not edit it by hand.
|
||||
|
||||
| Section | Field | Type | Required | Default | Description |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| site | `id` | string | yes | - | Stable site identifier used in output paths. |
|
||||
| site | `domain` | string | yes | - | Canonical public domain for the site. |
|
||||
| site.source | `kind` | enum | yes | static-html | Source adapter kind. |
|
||||
| site.source | `path` | path | yes | - | Source tree path. |
|
||||
| site.source | `canonical_url` | url | no | - | Canonical clearnet URL. |
|
||||
| site.outputs | `root` | path | yes | - | Generated output 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.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. |
|
||||
| site.protocols.<name> | `daemon_policy` | enum | no | protocol default | Whether AMPG adopts, manages, or only renders config. |
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated Daemon Adapters
|
||||
|
||||
> Status: generated | Updated by `python3 -m ampg docs generate` | Applies to: AMPG
|
||||
|
||||
This file is generated from code. Do not edit it by hand.
|
||||
|
||||
|
||||
## nginx
|
||||
|
||||
- Protocols: clearnet, tor, i2p
|
||||
- Default policy: `adopt`
|
||||
- Generated artifacts: server block snippets
|
||||
|
||||
Notes:
|
||||
- Clearnet defaults to adopt because TLS and public vhost policy are operator-owned.
|
||||
- Tor/I2P HTTP targets can use loopback-only server blocks.
|
||||
|
||||
## tor
|
||||
|
||||
- Protocols: tor
|
||||
- Default policy: `auto`
|
||||
- Generated artifacts: torrc hidden-service snippet
|
||||
|
||||
Notes:
|
||||
- AMPG should preserve existing HiddenServiceDir material when adopting.
|
||||
- Managed hidden-service keys belong under AMPG state.
|
||||
|
||||
## i2pd
|
||||
|
||||
- Protocols: i2p
|
||||
- Default policy: `auto`
|
||||
- Generated artifacts: i2pd server tunnel snippet
|
||||
|
||||
Notes:
|
||||
- Web tunnels should use web-specific key files.
|
||||
- Existing RPC/P2P tunnel keys are not reused for web publishing.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated Render Profiles
|
||||
|
||||
> Status: generated | Updated by `python3 -m ampg docs generate` | Applies to: AMPG
|
||||
|
||||
This file is generated from code. Do not edit it by hand.
|
||||
|
||||
|
||||
## clearnet
|
||||
|
||||
High-fidelity static web output.
|
||||
|
||||
- Output: HTML/CSS/JS/assets
|
||||
|
||||
Defaults:
|
||||
- Copies source files except hidden/editor/VCS clutter.
|
||||
- Keeps local JavaScript and rich media.
|
||||
- Writes only to AMPG-marked generated output roots.
|
||||
|
||||
## privacy-html
|
||||
|
||||
Static HTML for Tor and I2P.
|
||||
|
||||
- Output: HTML/CSS/assets
|
||||
|
||||
Defaults:
|
||||
- Removes active tags: script, style.
|
||||
- Removes inline event handlers such as onclick and onload.
|
||||
- Removes inline style attributes.
|
||||
- Skips JavaScript and source-map files.
|
||||
- Skips oversized assets above max_asset_bytes.
|
||||
- Removes remote src/poster/link asset references.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Interactive Applications
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: dynamic app support
|
||||
|
||||
AMPG must support real applications without pretending every transport can offer the same
|
||||
experience. The design goal is graceful capability mapping: expose the safest useful version
|
||||
of each surface on each protocol, and keep private surfaces private.
|
||||
|
||||
## Interaction tiers
|
||||
|
||||
`static`
|
||||
: Rendered files only. Works everywhere.
|
||||
|
||||
`forms`
|
||||
: Server-rendered GET/POST actions with full-page responses. Works on clearnet, Tor, I2P,
|
||||
and can be mapped to Gemini input prompts or Reticulum page scripts for simple actions.
|
||||
|
||||
`sessions`
|
||||
: Authenticated account flows. Supported on HTTP transports with strict cookies. Gemini can
|
||||
use client certificates or signed links later, but this is not a v1 requirement.
|
||||
|
||||
`realtime`
|
||||
: Rich browser UI, dashboards, live charts, websockets, or client-side state. HTTP transports
|
||||
only, and usually clearnet or private Tor/I2P.
|
||||
|
||||
`internal`
|
||||
: Worker APIs, webhooks, private admin APIs, payment callbacks, chain watchers, and service
|
||||
health endpoints. AMPG must not publish these automatically.
|
||||
|
||||
## Application adapter contract
|
||||
|
||||
An application adapter describes what AMPG may expose:
|
||||
|
||||
- upstream service and health check.
|
||||
- OpenAPI spec or route manifest when available.
|
||||
- route groups with interaction tier, auth requirement, and idempotency behavior.
|
||||
- form/action schemas for protocol translation.
|
||||
- public asset and cache policy.
|
||||
- private route denylist.
|
||||
|
||||
Adapters do not bypass the application. They translate constrained protocol input into the
|
||||
application's public action surface, then render safe responses.
|
||||
|
||||
## Protocol mapping
|
||||
|
||||
| Tier | Clearnet | Tor/I2P | Gemini | Reticulum |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `static` | yes | yes | yes | yes |
|
||||
| `forms` | yes | yes, no JS by default | simple single-field prompts | simple scripts/actions |
|
||||
| `sessions` | yes | yes, strict cookies | later | later |
|
||||
| `realtime` | yes | optional private surface | no | no |
|
||||
| `internal` | no public publish | no public publish | no | no |
|
||||
|
||||
## Challenge profile: Medusa multi-tenant platform
|
||||
|
||||
Source: `../medusa-multi-tenant-platform`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- multi-tenant domain routing.
|
||||
- storefront, tenant admin, backend, worker, database, Redis, S3/MinIO.
|
||||
- cart, checkout, orders, bookings, blogs, email, payment providers, and webhooks.
|
||||
- tenant-specific themes and custom domains.
|
||||
|
||||
AMPG support target:
|
||||
|
||||
- Clearnet: adopt existing Caddy/reverse-proxy deployment.
|
||||
- Tor/I2P: expose storefront routes with `forms` or `sessions` tier, no JavaScript by default.
|
||||
- Gemini/Reticulum: expose catalog, blog, policy, product detail, and inquiry/request flows.
|
||||
- Admin: `internal` unless explicitly bound to a private operator transport.
|
||||
- Webhooks, payment callbacks, worker queues, health internals: always `internal`.
|
||||
|
||||
Required adapter capabilities:
|
||||
|
||||
- read tenant/domain registry or accept an exported tenant manifest.
|
||||
- generate per-tenant protocol targets and daemon plans.
|
||||
- cap checkout/payment routes by protocol.
|
||||
- use OpenAPI route metadata where available.
|
||||
- never expose tenant admin or backend admin by wildcard.
|
||||
|
||||
## Challenge profile: evaluetron / ai-gen-bot
|
||||
|
||||
Source: `../ai-gen-bot`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
- OpenAPI-first core.
|
||||
- customer storefront plus operator console.
|
||||
- auth, payments, credit ledger, job queue, workers, moderation, and generated artifacts.
|
||||
- public web channel, Telegram channel, internal worker APIs, and admin APIs.
|
||||
|
||||
AMPG support target:
|
||||
|
||||
- Clearnet: public storefront and docs.
|
||||
- Tor/I2P: public storefront with server-rendered account/job flows when enabled.
|
||||
- Gemini/Reticulum: catalog browsing, deposit instructions, simple generation request, and job
|
||||
status lookup only after explicit adapter support.
|
||||
- Operator console: private clearnet, LAN, mesh, or private onion only.
|
||||
- Worker/internal routes: never published.
|
||||
|
||||
Required adapter capabilities:
|
||||
|
||||
- consume `api/openapi.yaml` as the action contract.
|
||||
- map job creation to idempotent form submissions.
|
||||
- render job status as polling-friendly pages.
|
||||
- hide unavailable generators when no worker advertises the required capability.
|
||||
- preserve the ledger and payment invariants of the upstream app.
|
||||
|
||||
## Design constraints learned from the challenge apps
|
||||
|
||||
- Public route discovery must start deny-first.
|
||||
- OpenAPI is useful, but not sufficient; route tiers and privacy intent must be explicit.
|
||||
- Admin and worker surfaces are separate sites or route groups, not accidental subpaths.
|
||||
- Gemini and Reticulum need curated actions, not automatic exposure of every POST route.
|
||||
- Multi-tenant apps need generated protocol targets per tenant, with shared daemon adapters.
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Classify every route group by interaction tier.
|
||||
- [ ] Deny private/admin/internal routes before enabling protocols.
|
||||
- [ ] Prefer OpenAPI or route manifests over HTML scraping for actions.
|
||||
- [ ] Provide safe alternates for constrained transports.
|
||||
- [ ] Verify no worker, webhook, payment callback, or admin route is publicly published.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Wownero Integration
|
||||
|
||||
> Status: draft | Updated 2026-07-05 | Applies to: Wownero static site publishing
|
||||
|
||||
The Wownero integration exercises AMPG against a real public static site. The source is
|
||||
the existing `wownero.org` website checked out next to this repository as
|
||||
`../wownero.org-website`.
|
||||
|
||||
## Objective
|
||||
|
||||
Start with the existing clearnet site, preserve the rich web output, and generate
|
||||
transport-specific variants with defaults:
|
||||
|
||||
- clearnet: faithful static site output.
|
||||
- Tor: privacy-hardened static HTML.
|
||||
- I2P: privacy-hardened static HTML.
|
||||
- Gemini: text-first `.gmi` pages.
|
||||
- Reticulum: compact Micron `.mu` pages.
|
||||
|
||||
## Source Adapter
|
||||
|
||||
The Wownero site is a static HTML/CSS/JS tree:
|
||||
|
||||
- top-level pages such as `index.html`, `mining.html`, `emission.html`, and
|
||||
`wownero-wallet-generator.html`.
|
||||
- local CSS, JavaScript, image, PDF, text, and icon assets.
|
||||
- JavaScript-driven translation and UI behavior.
|
||||
- an `onion-location` meta tag in `index.html`.
|
||||
|
||||
This makes `static-html` the right source adapter. Markdown migration is optional and
|
||||
must not be required for AMPG adoption.
|
||||
|
||||
## Generated Outputs
|
||||
|
||||
```text
|
||||
/var/www/ampg/wownero/
|
||||
clearnet/
|
||||
index.html
|
||||
css/
|
||||
js/
|
||||
img/
|
||||
tor/
|
||||
index.html
|
||||
mining.html
|
||||
emission.html
|
||||
i2p/
|
||||
index.html
|
||||
mining.html
|
||||
emission.html
|
||||
gemini/
|
||||
index.gmi
|
||||
mining.gmi
|
||||
emission.gmi
|
||||
reticulum/
|
||||
index.mu
|
||||
mining.mu
|
||||
emission.mu
|
||||
```
|
||||
|
||||
## Renderer Behavior
|
||||
|
||||
### Clearnet
|
||||
|
||||
Copy the source tree by default, excluding VCS/editor clutter, hidden private files, and
|
||||
backup artifacts.
|
||||
|
||||
### Tor and I2P
|
||||
|
||||
Use `privacy-html`:
|
||||
|
||||
- remove JavaScript.
|
||||
- remove inline event handlers.
|
||||
- remove inline styles.
|
||||
- keep local CSS only when configured.
|
||||
- preserve local links to PDFs, text files, and reasonably sized images.
|
||||
- preserve or generate onion/I2P location metadata only when configured.
|
||||
|
||||
### Gemini
|
||||
|
||||
Use `gemtext`:
|
||||
|
||||
- extract page title, headings, paragraphs, lists, and links.
|
||||
- emit links near the paragraph that referenced them.
|
||||
- convert large media to plain download links.
|
||||
- skip repeated navigation noise.
|
||||
|
||||
### Reticulum
|
||||
|
||||
Use `micron`:
|
||||
|
||||
- prioritize short informational pages.
|
||||
- omit heavy images by default.
|
||||
- generate compact navigation.
|
||||
- treat downloadable files as links with size hints.
|
||||
|
||||
## Daemon Policy
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
```toml
|
||||
[site.protocols.clearnet]
|
||||
enabled = true
|
||||
daemon_policy = "adopt"
|
||||
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
daemon_policy = "auto"
|
||||
|
||||
[site.protocols.i2p]
|
||||
enabled = false
|
||||
daemon_policy = "auto"
|
||||
|
||||
[site.protocols.gemini]
|
||||
enabled = false
|
||||
daemon_policy = "auto"
|
||||
|
||||
[site.protocols.reticulum]
|
||||
enabled = false
|
||||
daemon_policy = "auto"
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `ampg plan` identifies the Wownero source tree and selected protocols.
|
||||
- `ampg build` creates clearnet and Tor output without requiring Markdown.
|
||||
- Tor output contains no `<script>` tags or inline event handlers.
|
||||
- Generated Gemini output has readable headings, paragraphs, and links.
|
||||
- Generated Micron output fits terminal-first browsing.
|
||||
- `ampg plan` clearly says whether each selected daemon is adopted or managed.
|
||||
|
||||
## Local Commands
|
||||
|
||||
```sh
|
||||
python3 -m ampg --config examples/wownero.gateway.toml plan
|
||||
python3 -m ampg --config examples/wownero.gateway.toml build
|
||||
```
|
||||
|
||||
## Reusable checklist
|
||||
|
||||
- [ ] Build from `../wownero.org-website` using `source.kind = "static-html"`.
|
||||
- [ ] Preserve clearnet output first.
|
||||
- [ ] Generate Tor `privacy-html` and verify active content is stripped.
|
||||
- [ ] Add Gemini conversion fixtures.
|
||||
- [ ] Add Reticulum/Micron conversion fixtures.
|
||||
- [ ] Enable I2P and Reticulum daemon management only after static outputs are stable.
|
||||
@@ -0,0 +1,57 @@
|
||||
[gateway]
|
||||
state_dir = "./.ampg/state"
|
||||
cache_dir = "./.ampg/cache"
|
||||
run_dir = "./.ampg/run"
|
||||
user = "ampg"
|
||||
|
||||
[[site]]
|
||||
id = "wownero"
|
||||
domain = "wownero.org"
|
||||
|
||||
[site.source]
|
||||
kind = "static-html"
|
||||
path = "../../wownero.org-website"
|
||||
canonical_url = "https://wownero.org"
|
||||
|
||||
[site.outputs]
|
||||
root = "../dist/wownero"
|
||||
|
||||
[site.interactions]
|
||||
default_tier = "static"
|
||||
deny_routes = ["/admin/*", "/internal/*", "/webhooks/*"]
|
||||
|
||||
[site.protocols.clearnet]
|
||||
enabled = true
|
||||
renderer = "clearnet"
|
||||
daemon = "nginx"
|
||||
daemon_policy = "adopt"
|
||||
|
||||
[site.protocols.tor]
|
||||
enabled = true
|
||||
renderer = "privacy-html"
|
||||
daemon = "tor"
|
||||
daemon_policy = "auto"
|
||||
local_port = 18080
|
||||
onion_location = "auto"
|
||||
max_asset_bytes = 1048576
|
||||
|
||||
[site.protocols.i2p]
|
||||
enabled = false
|
||||
renderer = "privacy-html"
|
||||
daemon = "i2pd"
|
||||
daemon_policy = "auto"
|
||||
local_port = 18081
|
||||
|
||||
[site.protocols.gemini]
|
||||
enabled = false
|
||||
renderer = "gemtext"
|
||||
daemon = "agate"
|
||||
daemon_policy = "auto"
|
||||
port = 1965
|
||||
|
||||
[site.protocols.reticulum]
|
||||
enabled = false
|
||||
renderer = "micron"
|
||||
daemon = "rnsd"
|
||||
daemon_policy = "auto"
|
||||
aspect = "web"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "adaptive-multi-protocol-gateway"
|
||||
version = "0.1.0"
|
||||
description = "Static-first multi-protocol site compiler and ingress planner."
|
||||
requires-python = ">=3.11"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[project.scripts]
|
||||
ampg = "ampg.cli:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,30 @@
|
||||
import unittest
|
||||
|
||||
from ampg.renderers import render_privacy_html
|
||||
|
||||
|
||||
class PrivacyRendererTest(unittest.TestCase):
|
||||
def test_removes_scripts_event_handlers_and_inline_styles(self):
|
||||
html = (
|
||||
'<!doctype html><html><head><script src="app.js"></script></head>'
|
||||
'<body onload="boot()" style="color:red"><a onclick="x()" href="/ok">ok</a>'
|
||||
'<img src="https://example.com/x.png"><p>hello</p></body></html>'
|
||||
)
|
||||
|
||||
rendered, stats = render_privacy_html(html)
|
||||
|
||||
self.assertNotIn("<script", rendered)
|
||||
self.assertNotIn("onload", rendered)
|
||||
self.assertNotIn("onclick", rendered)
|
||||
self.assertNotIn("style=", rendered)
|
||||
self.assertNotIn("https://example.com/x.png", rendered)
|
||||
self.assertIn('<a href="/ok">ok</a>', rendered)
|
||||
self.assertIn("<p>hello</p>", rendered)
|
||||
self.assertEqual(stats.removed_active_tags, 1)
|
||||
self.assertEqual(stats.removed_event_handlers, 2)
|
||||
self.assertEqual(stats.removed_inline_styles, 1)
|
||||
self.assertEqual(stats.removed_remote_assets, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small documentation hygiene check for AMPG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LINK_RE = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
|
||||
|
||||
|
||||
def iter_markdown() -> list[Path]:
|
||||
return sorted(
|
||||
path
|
||||
for path in ROOT.rglob("*.md")
|
||||
if ".git" not in path.parts
|
||||
and "docs/generated" not in str(path)
|
||||
and "private" not in path.parts
|
||||
and "dist" not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def has_status_header(text: str) -> bool:
|
||||
return any(line.startswith("> Status:") for line in text.splitlines()[:6])
|
||||
|
||||
|
||||
def local_link_target(source: Path, raw_target: str) -> Path | None:
|
||||
target = raw_target.strip()
|
||||
if not target or target.startswith("#"):
|
||||
return None
|
||||
if "://" in target or target.startswith(("mailto:", "tel:")):
|
||||
return None
|
||||
target = target.split("#", 1)[0]
|
||||
if not target:
|
||||
return None
|
||||
return (source.parent / unquote(target)).resolve()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
files = iter_markdown()
|
||||
checked_links = 0
|
||||
|
||||
for path in files:
|
||||
rel = path.relative_to(ROOT)
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not has_status_header(text):
|
||||
errors.append(f"{rel}: missing > Status: header in first 6 lines")
|
||||
|
||||
for match in LINK_RE.finditer(text):
|
||||
target = local_link_target(path, match.group(1))
|
||||
if target is None:
|
||||
continue
|
||||
checked_links += 1
|
||||
if not target.exists():
|
||||
errors.append(f"{rel}: broken link -> {match.group(1)}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"DOCS_CHECK status=error message={error}")
|
||||
print(f"DOCS_CHECK status=fail files={len(files)} links={checked_links}")
|
||||
return 1
|
||||
|
||||
print(f"DOCS_CHECK status=ok files={len(files)} links={checked_links}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user