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:
@@ -294,6 +294,20 @@ class PreviewDomainRepository:
|
||||
async def count_for_project(self, project_id: str) -> int:
|
||||
return len(await self.list_for_project(project_id))
|
||||
|
||||
async def list_all(self, *, limit: int = 2_000) -> list[dict]:
|
||||
"""Every preview domain on the platform.
|
||||
|
||||
Deliberately **not tenant-scoped**: this is the owner console's view of
|
||||
the whole routing table, and it is reachable only from an
|
||||
``require_platform_owner`` route. Never use it for authorization.
|
||||
"""
|
||||
stmt = (
|
||||
select(PreviewDomainRow)
|
||||
.order_by(PreviewDomainRow.host.asc())
|
||||
.limit(min(max(limit, 1), 5_000))
|
||||
)
|
||||
return [self._row_to_dict(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
@@ -336,4 +350,7 @@ class PreviewDomainRepository:
|
||||
"protected": row.protected,
|
||||
"created_by_user_id": row.created_by_user_id,
|
||||
"created_at": row.created_at,
|
||||
# Derived, not stored: the scheme is always https and the host is the
|
||||
# identity. Kept here so every caller renders the same URL.
|
||||
"url": f"https://{row.host}",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user