# 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.` → `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.