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

118 lines
5.8 KiB
Markdown

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