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
@@ -17,8 +17,14 @@ from app.application.services.project_service import ProjectService
from app.core.database import get_session
from app.core.errors import NotFoundError
from app.domain.audit.models import AuditAction
from app.domain.identity.models import CAN_MANAGE_PROJECTS
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
from app.domain.identity.models import CAN_MANAGE_PROJECTS, User
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
get_project_service,
require_platform_owner,
)
router = APIRouter(
prefix="/organizations/{org_id}/projects", tags=["projects"]
@@ -310,8 +316,14 @@ async def create_preview_domain(
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
owner: User = Depends(require_platform_owner),
) -> PreviewDomainOut:
# v2 (D9): attaching a host writes a routing rule and a certificate on a
# shared host — infrastructure, and infrastructure is the platform owner's
# alone. The demo host is still created through this route at app-creation
# time; the owner is simply the only actor who can reach it.
context.require(CAN_MANAGE_PROJECTS)
_ = owner
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
record = await service.create_preview_domain(
@@ -362,8 +374,10 @@ async def delete_preview_domain(
context: OrgContext = Depends(get_org_context),
service: ProjectService = Depends(get_project_service),
session: AsyncSession = Depends(get_session),
owner: User = Depends(require_platform_owner),
) -> None:
context.require(CAN_MANAGE_PROJECTS)
_ = owner
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
await service.remove_preview_domain(project=project, domain_id=domain_id)