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:
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user