Files
mjalmoudy94 82cea1e690 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
2026-10-14 00:57:08 +03:30

5.8 KiB

Agent 3 — Phase 4 report: domains owned by the platform owner

Date: 2026-10-14 · Branch: dev

What was done

4.1 admin_domains.py — the owner's routing console

Four routes under /api/admin/domains, all require_platform_owner:

Method Path Purpose
GET /admin/domains every route, with its owning org/app and target port
POST /admin/domains attach an extra host to an app
DELETE /admin/domains/{id} detach, with the host typed back as confirmation
POST /admin/domains/sync converge the router on the database
GET /admin/domains/suggested system routes the owner may enable (the MCP gateway)

Attaching a host points it at whatever port the app already serves on (demo, then web), so the owner never has to look a port up. Detaching requires the host typed back — removing a route is what makes a live URL stop working, so it gets the same typed confirmation as a production deploy — and is audited (domain.detach).

Users cannot attach domains. The member-facing POST/DELETE .../projects/{id}/preview/domains routes in projects.py are now owner-only. They are kept rather than deleted because the demo host is still created through them at app-creation time; the owner is simply the only actor who can reach them.

The host rule (domain/platform/rules.py)

validate_extra_host(host, root_domain, allow_custom) accepts exactly two shapes:

  • a single label under the managed root (shop.msa.artacloud.ir) — the default; and
  • a full custom FQDN (shop.customer.example), only when ALLOW_CUSTOM_DOMAINS is on. A custom host needs its own DNS record and certificate, and accepting one that will never resolve produces a domain that looks configured and is not.

A wildcard is refused in both shapes — *.example.com in a Caddy site block matches every subdomain, including another tenant's. So is a 30-name list of protected platform labels (git, mcp, panel, api, www, …): a tenant host that resolves to the forge or the console is both a phishing surface and a routing conflict with a real service. Hosts are normalised (lower-cased, trailing dot stripped, length-capped).

The rule is enforced twice — in the router and again inside the adapter — because a rule checked in one place is a rule that will eventually be bypassed.

4.2 Re-sync is idempotent

Both adapters gained routes_fingerprint(), list_all_routes() and sync_routes(desired):

  • sync_routes renders the desired state, compares it against what is on disk, and writes and reloads only when it differs. Pressing the console's re-sync button must not drop every in-flight connection on the host, including tenants' live demos.
  • The mock adapter applies the same validation, so a test that passes against the mock describes behaviour the real adapter also guarantees.

sync_routes/routes_fingerprint are adapter-level extras, not additions to DomainRouterPort — that contract is frozen by the plan. The router declares a local SyncableRouter protocol and reports "this router cannot re-sync" rather than crashing against a router that does not implement them.

4.3 The MCP gateway as a suggested route

GET /admin/domains/suggested offers mcp.<root> → MCP_GATEWAY_PORT. It is suggested rather than created automatically: it needs a DNS record and a certificate before it can work, and creating it eagerly would produce a host that looks live and is not. enabled reflects whether the route already exists.

Files changed

File Change
app/domain/platform/rules.py validate_extra_host, PROTECTED_HOST_LABELS
app/infrastructure/adapters/caddy.py routes_fingerprint, list_all_routes, sync_routes
app/infrastructure/adapters/mock.py the same three, with matching validation
app/infrastructure/repositories/projects.py PreviewDomainRepository.list_all(), derived url
app/interfaces/http/routers/admin_domains.py 5 owner-only routes
app/interfaces/http/routers/projects.py preview-domain create/delete are owner-only
tests/test_admin_domains.py new — 58 tests

Test output

apps/api:  473 passed, 1 skipped (was 415; +58)
           ruff check .  -> All checks passed!
           mypy app      -> Success: no issues found in 132 source files
apps/web:  67 passed (unchanged)

Deviations from the TODO

  1. sync_routes lives on the adapters, not the port. The TODO says "the Caddy adapter keeps validate → revert → reload. Add a routes_fingerprint check"; adding either to DomainRouterPort would change a frozen contract, so a local protocol covers it.
  2. Detach requires the host typed back (the TODO says "confirm + audit"). A boolean confirm would be one more thing to click through; typing the host is the same pattern the production deploy uses, so operators meet it once and recognise it.
  3. GET /admin/domains/suggested is new. The TODO asks for the MCP host to be "recorded as a protected system route that the owner can enable from this page"; without a route to read the suggestion the page has nothing to render.

Open questions / CHANGE REQUESTS

  • None. DomainRouterPort and platform/rules.py's existing exports are unchanged; everything new is additive.

Known limitations

  • A custom FQDN needs its DNS record and certificate to be arranged outside the platform (Caddy will attempt ACME on reload, which fails for a host that does not resolve). The console says so; it cannot do it for the operator.
  • sync_routes is all-or-nothing: one invalid row in the ledger refuses the whole re-sync rather than applying the valid subset. That is deliberate — a partial sync is harder to reason about than a refusal — but it does mean one bad row blocks the button until it is fixed.