diff --git a/README.md b/README.md index 338fc19..0ba48f8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ internal even when the public site is available on Tor or I2P. - [Architecture](docs/architecture.md) - [Configuration](docs/configuration.md) - [Daemon management](docs/daemon-management.md) +- [Fixture manifests](docs/fixture-manifests.md) - [Interactive applications](docs/interactive-apps.md) - [Wownero integration](docs/wownero-integration.md) - [Autodocs and drift gates](docs/autodocs.md) @@ -97,6 +98,7 @@ The current implementation is dependency-free Python: python3 -m ampg --config examples/wownero.gateway.toml plan python3 -m ampg --config examples/wownero.gateway.toml plan --write-artifacts python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/wownero.gateway.toml manifest python3 -m ampg --config examples/wownero.gateway.toml audit python3 -m ampg --config examples/i2p-only.gateway.toml build python3 -m ampg docs generate @@ -116,4 +118,5 @@ passed. They are reviewable snippets, not installed daemon config. - [ ] Select protocols and daemon policy. - [ ] Run `ampg plan`; review output roots, ports, and config changes. - [ ] Run `ampg build`; inspect generated variants. +- [ ] Run AMPB fixture checks against generated manifests. - [ ] Run `ampg apply`; publish through adopted or managed daemons. diff --git a/ampg/cli.py b/ampg/cli.py index 3388797..07ee88e 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -8,6 +8,7 @@ from .audit import audit_gateway from .build import build_gateway from .config import load_config from .docsgen import generate_docs +from .manifest import write_fixture_manifests from .plan import plan_gateway, write_plan_artifacts @@ -27,6 +28,7 @@ def main(argv: list[str] | None = None) -> int: help="Write generated config snippets to the configured plan root.", ) subcommands.add_parser("build", help="Build enabled protocol outputs.") + subcommands.add_parser("manifest", help="Write AMPB fixture manifests.") audit_parser = subcommands.add_parser("audit", help="Audit source HTML quality.") audit_parser.add_argument( "--fail-on-warn", @@ -52,6 +54,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_plan(config, write_artifacts=args.write_artifacts) if args.command == "build": return _cmd_build(config) + if args.command == "manifest": + return _cmd_manifest(config) if args.command == "audit": return _cmd_audit(config, fail_on_warn=args.fail_on_warn) except Exception as exc: # noqa: BLE001 - CLI should print concise failures. @@ -102,6 +106,24 @@ def _cmd_build(config) -> int: f" skipped={result.files_skipped}" f"{extra}" ) + for manifest in write_fixture_manifests(config): + print( + "AMPG_MANIFEST " + f"site={manifest.site_id} " + f"path={manifest.path} " + f"fixtures={manifest.fixture_count}" + ) + return 0 + + +def _cmd_manifest(config) -> int: + for manifest in write_fixture_manifests(config): + print( + "AMPG_MANIFEST " + f"site={manifest.site_id} " + f"path={manifest.path} " + f"fixtures={manifest.fixture_count}" + ) return 0 diff --git a/ampg/config.py b/ampg/config.py index 0779683..39287cd 100644 --- a/ampg/config.py +++ b/ampg/config.py @@ -12,6 +12,7 @@ DEFAULT_DAEMONS = { "i2p": "i2pd", "gemini": "agate", "reticulum": "rnsd", + "ipfs": "ipfs", } DEFAULT_POLICIES = { @@ -20,6 +21,7 @@ DEFAULT_POLICIES = { "i2p": "auto", "gemini": "auto", "reticulum": "auto", + "ipfs": "auto", } diff --git a/ampg/manifest.py b/ampg/manifest.py new file mode 100644 index 0000000..247ec2b --- /dev/null +++ b/ampg/manifest.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from .config import GatewayConfig, ProtocolConfig, SiteConfig + + +SCHEMA = "ampg.fixture-manifest.v1" + + +@dataclass(frozen=True) +class ManifestWriteResult: + site_id: str + path: Path + fixture_count: int + + +def fixture_manifest(site: SiteConfig) -> dict[str, Any]: + fixtures = [ + _fixture_entry(site, protocol) + for protocol in site.protocols.values() + if protocol.enabled + ] + return { + "schema": SCHEMA, + "site": { + "id": site.id, + "domain": site.domain, + "canonical_url": site.source.canonical_url or "", + }, + "fixtures": fixtures, + } + + +def write_fixture_manifests(config: GatewayConfig) -> list[ManifestWriteResult]: + results: list[ManifestWriteResult] = [] + for site in config.sites: + manifest = fixture_manifest(site) + path = manifest_path(site) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + results.append( + ManifestWriteResult( + site_id=site.id, + path=path, + fixture_count=len(manifest["fixtures"]), + ) + ) + return results + + +def manifest_path(site: SiteConfig) -> Path: + return site.outputs.root / "ampg-fixture-manifest.json" + + +def _fixture_entry(site: SiteConfig, protocol: ProtocolConfig) -> dict[str, Any]: + url, address_status = _fixture_url(site, protocol) + transport = _transport_for(protocol.name) + return { + "protocol": protocol.name, + "renderer": protocol.renderer, + "url": url, + "address_status": address_status, + "output_path": protocol.name, + "checks": { + "transport": transport, + "profile": transport, + }, + } + + +def _transport_for(protocol_name: str) -> str: + if protocol_name in {"clearnet", "tor", "i2p", "gemini", "reticulum", "ipfs"}: + return protocol_name + return protocol_name + + +def _fixture_url(site: SiteConfig, protocol: ProtocolConfig) -> tuple[str, str]: + configured_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url") + if configured_url: + return _with_trailing_slash(str(configured_url)), "configured" + + if protocol.name == "clearnet": + if site.source.canonical_url: + return _with_trailing_slash(site.source.canonical_url), "configured" + return f"https://{site.domain}/", "derived" + + if protocol.name == "tor": + onion_url = protocol.options.get("onion_url") + if onion_url: + return _with_trailing_slash(str(onion_url)), "configured" + onion_location = str(protocol.options.get("onion_location", "auto")) + if onion_location and onion_location != "auto": + return _with_scheme(onion_location, "http"), "configured" + return f"http://{site.id}.onion/", "placeholder" + + if protocol.name == "i2p": + i2p_url = protocol.options.get("i2p_url") or protocol.options.get("i2p_hostname") + if i2p_url: + return _with_scheme(str(i2p_url), "http"), "configured" + return f"http://{site.id}.i2p/", "placeholder" + + if protocol.name == "gemini": + gemini_url = protocol.options.get("gemini_url") + if gemini_url: + return _with_scheme(str(gemini_url), "gemini"), "configured" + return f"gemini://{site.domain}/", "derived" + + if protocol.name == "reticulum": + rns_url = protocol.options.get("rns_url") + if rns_url: + return _with_scheme(str(rns_url), "rns"), "configured" + return f"rns://{site.id}", "placeholder" + + if protocol.name == "ipfs": + ipfs_url = protocol.options.get("ipfs_url") + if ipfs_url: + return _with_scheme(str(ipfs_url), "ipfs"), "configured" + cid = protocol.options.get("cid") + if cid: + return f"ipfs://{cid}", "configured" + return f"/ipfs/{site.id}", "placeholder" + + return f"https://{site.domain}/", "derived" + + +def _with_scheme(value: str, scheme: str) -> str: + if "://" in value: + return _with_trailing_slash(value) + return _with_trailing_slash(f"{scheme}://{value}") + + +def _with_trailing_slash(value: str) -> str: + if value.startswith(("rns://", "lxmf://", "nomad://", "ipfs://", "ipns://")): + return value + if value.endswith("/"): + return value + return value + "/" diff --git a/ampg/metadata.py b/ampg/metadata.py index 04c11cd..78f21bd 100644 --- a/ampg/metadata.py +++ b/ampg/metadata.py @@ -188,4 +188,14 @@ DAEMON_ADAPTERS = ( "Certificate and key paths are plan values until install/apply support exists.", ), ), + DaemonAdapter( + daemon=DEFAULT_DAEMONS["ipfs"], + protocols=("ipfs",), + default_policy=DEFAULT_POLICIES["ipfs"], + generated_artifacts=("fixture manifest route expectations",), + notes=( + "IPFS output is static web content for a local gateway or later pinning.", + "IPFS is content-addressed distribution, not an anonymity layer.", + ), + ), ) diff --git a/docs/architecture.md b/docs/architecture.md index 4f28906..ea6b247 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ source site -> render profiles -> output roots -> daemon adapters - -> clearnet / Tor / I2P / Gemini / Reticulum + -> clearnet / Tor / I2P / Gemini / IPFS / Reticulum ``` ## Source adapters @@ -69,6 +69,10 @@ own output. : Gemini output. Converts headings, paragraphs, lists, code blocks, and links to `.gmi`. Inline links become block links near the paragraph by default. +`clearnet` for IPFS +: Static web output suitable for a local IPFS gateway or later pinning. IPFS is treated + as content-addressed distribution, not an anonymity layer. + `micron` : Reticulum/Nomad output. Produces terminal-oriented Micron markup with small pages, plain links, and no heavy media by default. @@ -82,6 +86,7 @@ Daemon adapters provide a common interface: - `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. +- `manifest`: emit AMPB route/profile expectations for generated fixtures. 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. diff --git a/docs/configuration.md b/docs/configuration.md index c935805..4a37d24 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,6 +66,13 @@ daemon_policy = "auto" port = 1965 max_asset_bytes = 1048576 +[site.protocols.ipfs] +enabled = false +renderer = "clearnet" +daemon = "ipfs" +daemon_policy = "auto" +cid = "" + [site.protocols.reticulum] enabled = false renderer = "micron" @@ -86,6 +93,7 @@ workflow. | tor | `privacy-html` | `tor` | `auto` | | i2p | `privacy-html` | `i2pd` | `auto` | | gemini | `gemtext` | `agate` | `auto` | +| ipfs | `clearnet` | `ipfs` | `auto` | | reticulum | `micron` | `rnsd` | `auto` | ## Renderer defaults @@ -178,7 +186,7 @@ omits it or renders a safe alternate page. ## Protocol-only deployments Enabled protocols are independent. A config can omit clearnet entirely and publish only -to I2P, Tor, Gemini, Reticulum, or any combination of supported targets. +to I2P, Tor, Gemini, IPFS, Reticulum, or any combination of supported targets. For an existing HTML site that should publish only to I2P: @@ -209,6 +217,9 @@ script_policy = "strip" This builds only the I2P output root and only the I2P/nginx plan snippets. +For IPFS, use the `clearnet` renderer to create a static output tree that can be served +through a local gateway or pinned by a later publishing step. + ## Plan artifacts `ampg plan` prints expected artifact paths. `ampg plan --write-artifacts` writes diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md new file mode 100644 index 0000000..c50ee82 --- /dev/null +++ b/docs/fixture-manifests.md @@ -0,0 +1,41 @@ +# Fixture Manifests + +> Status: draft | Updated 2026-07-06 | Applies to: AMPG and AMPB compatibility checks + +AMPG writes deterministic fixture manifests so AMPB can verify that generated site +variants route to the expected browser transport and profile. + +## Output + +`ampg build` writes one manifest per site: + +```text +/ampg-fixture-manifest.json +``` + +The manifest contains public route expectations only: + +- site id, domain, and canonical URL. +- enabled protocol fixtures. +- protocol renderer and relative output path. +- route URL for AMPB checks. +- expected AMPB transport and profile. + +It must not contain private host inventory, hidden-service keys, tunnel keys, credentials, +or deployment notes. + +## Commands + +```sh +python3 -m ampg --config examples/wownero.gateway.toml build +python3 -m ampg --config examples/wownero.gateway.toml manifest +``` + +`build` emits the manifest after writing protocol outputs. `manifest` rewrites only the +manifest and does not rebuild protocol outputs. + +## Address Status + +Some transports may not have final public addresses when a fixture is generated. In those +cases AMPG uses route-valid placeholders such as `http://wownero.onion/` so AMPB can still +verify transport selection without contacting the network. diff --git a/docs/generated/daemon-adapters.md b/docs/generated/daemon-adapters.md index 6b0d919..9ea6173 100644 --- a/docs/generated/daemon-adapters.md +++ b/docs/generated/daemon-adapters.md @@ -44,3 +44,13 @@ Notes: Notes: - Gemini serves generated Gemtext output directly. - Certificate and key paths are plan values until install/apply support exists. + +## ipfs + +- Protocols: ipfs +- Default policy: `auto` +- Generated artifacts: fixture manifest route expectations + +Notes: +- IPFS output is static web content for a local gateway or later pinning. +- IPFS is content-addressed distribution, not an anonymity layer. diff --git a/docs/wownero-integration.md b/docs/wownero-integration.md index c958b10..1d3cccc 100644 --- a/docs/wownero-integration.md +++ b/docs/wownero-integration.md @@ -15,6 +15,7 @@ transport-specific variants with defaults: - Tor: privacy-hardened static HTML. - I2P: privacy-hardened static HTML. - Gemini: text-first `.gmi` pages. +- IPFS: static output tree for gateway or pinning workflows. - Reticulum: compact Micron `.mu` pages. ## Source Adapter @@ -51,6 +52,10 @@ must not be required for AMPG adoption. index.gmi mining.gmi emission.gmi + ipfs/ + index.html + css/ + img/ reticulum/ index.mu mining.mu @@ -84,6 +89,11 @@ Use `gemtext`: - convert large media to plain download links. - skip repeated navigation noise. +### IPFS + +Use `clearnet` static output for content-addressed publishing. AMPG emits route +expectations for AMPB, but pinning and CID publication remain explicit operator actions. + ### Reticulum Use `micron`: @@ -114,6 +124,11 @@ daemon_policy = "auto" enabled = false daemon_policy = "auto" +[site.protocols.ipfs] +enabled = false +renderer = "clearnet" +daemon_policy = "auto" + [site.protocols.reticulum] enabled = false daemon_policy = "auto" @@ -123,6 +138,7 @@ daemon_policy = "auto" - `ampg plan` identifies the Wownero source tree and selected protocols. - `ampg build` creates clearnet, Tor, I2P, and Gemini output without requiring Markdown. +- `ampg build` writes a fixture manifest that AMPB can check. - Tor output contains no `