commit e01d35132f65a0bebf16b70b2619f8a963a5181b Author: jwinterm Date: Sun Jul 5 19:29:00 2026 -0400 Initial AMPG spec and Wownero prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2358e92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.ampg/ +/dist/ +/docs/private/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..99c9195 --- /dev/null +++ b/README.md @@ -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. diff --git a/ampg/__init__.py b/ampg/__init__.py new file mode 100644 index 0000000..8432199 --- /dev/null +++ b/ampg/__init__.py @@ -0,0 +1,5 @@ +"""Adaptive Multi-Protocol Gateway.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/ampg/__main__.py b/ampg/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/ampg/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ampg/build.py b/ampg/build.py new file mode 100644 index 0000000..60f40c7 --- /dev/null +++ b/ampg/build.py @@ -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") diff --git a/ampg/cli.py b/ampg/cli.py new file mode 100644 index 0000000..00cdbbd --- /dev/null +++ b/ampg/cli.py @@ -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 diff --git a/ampg/config.py b/ampg/config.py new file mode 100644 index 0000000..0c1c5e7 --- /dev/null +++ b/ampg/config.py @@ -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 diff --git a/ampg/docsgen.py b/ampg/docsgen.py new file mode 100644 index 0000000..5deeccd --- /dev/null +++ b/ampg/docsgen.py @@ -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) diff --git a/ampg/metadata.py b/ampg/metadata.py new file mode 100644 index 0000000..11dfd8b --- /dev/null +++ b/ampg/metadata.py @@ -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", + False, + "[]", + "Route patterns that must not be published.", + ), + ConfigField( + "site.protocols.", + "enabled", + "boolean", + False, + "false", + "Whether this protocol target is built and planned.", + ), + ConfigField( + "site.protocols.", + "renderer", + "enum", + False, + "", + "Renderer profile to use for this protocol.", + ), + ConfigField( + "site.protocols.", + "daemon", + "enum", + False, + "protocol default", + "Ingress daemon adapter.", + ), + ConfigField( + "site.protocols.", + "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.", + ), + ), +) diff --git a/ampg/plan.py b/ampg/plan.py new file mode 100644 index 0000000..fef60e5 --- /dev/null +++ b/ampg/plan.py @@ -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" diff --git a/ampg/renderers.py b/ampg/renderers.py new file mode 100644 index 0000000..3ec6a7c --- /dev/null +++ b/ampg/renderers.py @@ -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"") + + 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"") + + 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("//") diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..523e705 --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/autodocs.md b/docs/autodocs.md new file mode 100644 index 0000000..160c30f --- /dev/null +++ b/docs/autodocs.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..265fc88 --- /dev/null +++ b/docs/configuration.md @@ -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: + +- `' + 'ok' + '

hello

' + ) + + rendered, stats = render_privacy_html(html) + + self.assertNotIn("ok', rendered) + self.assertIn("

hello

", 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() diff --git a/tools/docs_check.py b/tools/docs_check.py new file mode 100644 index 0000000..c3c54c4 --- /dev/null +++ b/tools/docs_check.py @@ -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"(? 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())