feat(delivery): owner-only domains + idempotent route re-sync (v2 phase A3-4)

Domains are infrastructure, and in v2 infrastructure is the platform owner's
alone (D9). A tenant gets one automatic demo host and nothing else.

- admin_domains.py (owner-only): list every route with its owning app, attach an
  extra host, detach with the host typed back as confirmation (audited), re-sync
  the router from the database, and read the suggested MCP gateway route.
- domain/platform/rules.py: validate_extra_host. Two shapes only — a single
  label under the managed root, or a full custom FQDN when ALLOW_CUSTOM_DOMAINS
  is on. A wildcard is refused in both (it would match another tenant's
  subdomain), and so are 30 protected platform labels (git, mcp, panel, api...).
  Enforced in the router *and* again in the adapter.
- Both router adapters gained routes_fingerprint / list_all_routes /
  sync_routes. Re-sync writes and reloads only when the rendered state differs:
  a reload drops every in-flight connection on the host, including live demos.
  Adapter-level extras, not additions to the frozen DomainRouterPort.
- The member-facing preview-domain routes are owner-only now. They are kept
  because the demo host is still created through them at app-creation time.

Tests: 473 API (+58); ruff and mypy clean.

Report: docs/plan/reports/agent-3-phase-4.md
This commit is contained in:
mjalmoudy94
2026-10-14 00:57:08 +03:30
parent a1b8a28dda
commit 82cea1e690
8 changed files with 1059 additions and 4 deletions
@@ -36,6 +36,8 @@ from app.application.ports import (
WorkspaceInfo,
)
from app.domain.platform.rules import (
RuleViolation,
assert_not_protected_port,
assert_port_allocatable,
build_host,
validate_managed_host,
@@ -250,6 +252,49 @@ class MockDomainRouterAdapter:
if h in self._domains
]
# -- full re-sync (owner console) ----------------------------------------
def routes_fingerprint(self) -> str:
"""Stable digest of the routes currently registered."""
material = "\n".join(sorted(f"{h}:{i.port}" for h, i in self._domains.items()))
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:32]
async def list_all_routes(self) -> list[PreviewDomainInfo]:
return [self._domains[h] for h in sorted(self._domains)]
async def sync_routes(self, routes: list[tuple[str, int]]) -> ReverseProxyResult:
"""Make the mock ledger match ``routes`` exactly.
Applies the same validation as the Caddy adapter, so a test that passes
against the mock describes behaviour the real adapter also guarantees.
"""
try:
desired = {
validate_managed_host(host, self.root_domain): int(port)
for host, port in routes
}
except (RuleViolation, ValueError) as exc:
return ReverseProxyResult(ok=False, message=str(exc))
for port in desired.values():
try:
validate_proxy_target(f"localhost:{port}")
assert_not_protected_port(port)
except RuleViolation as exc:
return ReverseProxyResult(ok=False, message=str(exc))
if {h: i.port for h, i in self._domains.items()} == desired:
return ReverseProxyResult(ok=True, message="مسیرها از قبل همگام بودند.")
for host in list(self._domains):
if host not in desired:
self._domains.pop(host, None)
for host, port in desired.items():
self._domains[host] = PreviewDomainInfo(
host=host, port=port, url=f"https://{host}", status="active"
)
return ReverseProxyResult(ok=True, message=f"{len(desired)} مسیر همگام شد.")
# ---------------------------------------------------------------------------
# Sandbox