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
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 whenALLOW_CUSTOM_DOMAINSis 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_routesrenders 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
sync_routeslives on the adapters, not the port. The TODO says "the Caddy adapter keeps validate → revert → reload. Add aroutes_fingerprintcheck"; adding either toDomainRouterPortwould change a frozen contract, so a local protocol covers it.- Detach requires the host typed back (the TODO says "confirm + audit"). A boolean
confirmwould 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. GET /admin/domains/suggestedis 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.
DomainRouterPortandplatform/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_routesis 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.