feat(admin-console): add-domain flow + DNS propagation status panel (#829) (#834)

* feat(unified-rbac): SME-tier extension + host-header tenant discovery (#802)

Implements the SME-tier extension to the existing Sovereign Console SPA
per [Q-mine-1] of #795: same React bundle serves both otech-admin and
SME-admin views, tenant context discovered via window.location.host
against a back-end registry — not from path/subdomain string parsing.

Backend (catalyst-api / unified-rbac slice):
- Tenant registry (store.TenantRegistry) — flat-file host → tenant
  lookup table backing the public discovery endpoint. Host normalised
  to lowercase; case-insensitive lookups.
- GET /api/v1/tenant/discover (public, no auth gate) — returns
  {tenant_id, tenant_kind, keycloak_realm_url, keycloak_client_id} on
  200, 404 on unknown host, 503 if registry unwired. Admin URLs are
  NEVER on this wire.
- POST /api/v1/sme/users — fires ADR-0003 3-step hook (Keycloak →
  NewAPI → K8s Secret SSA with field manager `unified-rbac`). Each
  step idempotent; persisted state machine in store.UserProvisionStore
  per ADR-0003 §3.4. Returns 202 with steps[] progress array so the
  SPA can render the 3-step indicator even on partial failure.
- GET /api/v1/sme/users / DELETE /api/v1/sme/users/{uuid} — list +
  inverse rollback per ADR-0003 §3.7.
- internal/newapi.Client — minimal NewAPI admin REST client; 201
  happy-path + 409 idempotent recovery via GET ?external_id=<uuid>
  per ADR-0003 §3.2 (NewAPI does NOT rotate api_key on conflict).

Frontend (Sovereign Console SPA):
- Branded TenantID + TenantKind types (shared/types/tenant.ts) — same
  pattern as DeploymentID (#749).
- shared/lib/tenantDiscover.ts — fire-and-forget discovery in main.tsx;
  result cached in module state for sidebar nav + OIDC bootstrap.
- pages/sme/UsersPage.tsx — user CRUD UI with 3-step KC/NewAPI/Secret
  progress indicator wired off the API response shape.
- pages/sme/RolesPage.tsx — canonical Keycloak group → app role map
  (wordpress / openclaw / stalwart / rbac) per #795 [B].
- pages/sme/sme.api.ts — typed REST client; X-Tenant-Host header
  carries window.location.host on every call.
- Routes mounted at /console/sme/users + /console/sme/roles under the
  existing SovereignConsoleLayout — same SPA bundle, different route
  tree per discovered tenant_kind.

Tests: 22 new UI tests (4 files), 33 new Go tests (4 files). All
green: branded type parsers reject empty/non-string inputs, tenant
discovery handles 200/404/503/network-error paths, the 3-step hook
runs end-to-end against fake KC/NewAPI/SSA stubs, partial-failure
states surface verbatim through the steps[] response field, public
discovery endpoint never leaks admin URLs.

Per docs/INVIOLABLE-PRINCIPLES.md #4 every URL goes through apiUrl()
in shared/config/urls; per #2 wire shapes parse through branded-type
parsers at the boundary; per #3 K8s Secret apply uses client-go SSA
(field manager `unified-rbac`) — no exec.Command kubectl shell-out.

Closes #802.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(unified-rbac): add Playwright E2E for SME-tier UI (#802)

Three specs covering:
- SME UsersPage: empty state → create form → 3-step progress
  indicator (KC done / NewAPI done / Secret done) — proves the
  page is wired to the API response shape.
- SME RolesPage: canonical group → app-role table renders the
  full 7-row mapping locked in #795 [B].
- OTECH tenant: same SPA bundle navigates /console/dashboard for
  the otech discovery payload — proves [Q-mine-1] of #795
  (one bundle, two route trees, host-driven discovery).

Backend mocks: route fulfillers stub /tenant/discover, /sme/users,
and /whoami so the dev-server harness can drive the SPA without
the catalyst-api backend or a live SME vcluster. The full live
cross-cluster E2E gates on bp-newapi (#799) seeding the tenant
registry at SME-onboarding time, which lands in #804.

1440 px screenshots captured at e2e/screenshots/802-*.png:
- 802-sme-users-empty-1440.png
- 802-sme-users-create-form-1440.png
- 802-sme-users-after-create-1440.png
- 802-sme-roles-1440.png
- 802-otech-dashboard-same-bundle-1440.png

Run: VITE_CATALYST_MODE=sovereign VITE_SOVEREIGN_FQDN=acme.otech.example
     npm run dev
     npx playwright test e2e/sme-tier-rbac.spec.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(admin-console): add-domain flow + DNS propagation status panel (#829)

Multi-domain Sovereign — operator-admin "Add another parent domain"
surface in the Sovereign Console + live DNS propagation status panel.
Closes the MD-4 sub-ticket of epic #825.

Backend (catalyst-api/internal/handler/parent_domains.go):
- GET    /api/v1/sovereign/parent-domains             — list pool
- POST   /api/v1/sovereign/parent-domains             — add domain
- DELETE /api/v1/sovereign/parent-domains/{name}      — remove
- GET    /api/v1/sovereign/parent-domains/{name}/propagation
                                                      — fan-out to 5+
                                                        public DNS resolvers

The Add pipeline calls PDM /set-ns (sister #826), creates the PowerDNS
zone (sister #827, env-gated stub until that PR lands), and issues a
wildcard cert via cert-manager (also sister #827, env-gated stub). All
three steps update the same store row so the UI can render per-step
progress.

DNS propagation panel uses Go's net.Resolver with a custom Dial that
routes lookups through a SPECIFIC resolver IP (8.8.8.8, 1.1.1.1,
9.9.9.9, 208.67.222.222, 4.2.2.1) rather than the system resolver.
Per inviolable principle #4, the resolver list, expected NS records,
and per-query timeout are all env-overridable.

Frontend (ui/src/pages/admin/parent-domains/):
- ParentDomainsPage.tsx — list view + Add Domain modal + per-row
  inline drawer with PropagationPanel
- PropagationPanel.tsx — polls /propagation every 60s, renders
  green/yellow/red pills per resolver + rolling % propagated number
- parentDomains.api.ts — typed REST client wrappers, no inline /api/

Routing:
- /console/parent-domains registered under SovereignConsoleLayout
- Added to Settings sub-nav for operator-admin reachability

Tests:
- 6 vitest cases (empty state, populated rows, modal open, drawer
  toggle, primary lock, propagation panel mount)
- 13 Go cases covering list/add/delete/validation/propagation wire
  shape against a stub PDM
- 3 Playwright E2E + 1440x900 screenshots:
  e2e/screenshots/829-1-just-flipped.png       (0% propagated)
  e2e/screenshots/829-2-partially-propagated.png (40%)
  e2e/screenshots/829-3-fully-propagated.png   (100%)

Per inviolable principle #10 (credential hygiene) the registrarToken
field is forwarded byte-for-byte to PDM and never enters a logged
struct; the modal input uses type="password".

Refs: #825 (parent epic), #826 (sister MD-1), #827 (sister MD-2)

---------

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-04 23:31:03 +04:00 committed by GitHub
parent ec07488226
commit 620d8b6c13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2424 additions and 0 deletions

View File

@ -586,6 +586,19 @@ func main() {
rg.Post("/api/v1/sovereign/cutover/start", h.HandleCutoverStart)
rg.Get("/api/v1/sovereign/cutover/status", h.HandleCutoverStatus)
rg.Get("/api/v1/sovereign/cutover/events", h.HandleCutoverEvents)
// Multi-domain Sovereign — admin "Add another parent domain" flow
// + live DNS propagation status panel (issue #829, parent #825).
// LIST returns the operator's parent-domain pool (primary +
// sme-pool entries). POST queues a new domain through the
// NS-flip → PowerDNS-zone-create → cert-issue pipeline.
// /propagation fans out to 5 public DNS resolvers via Go's
// net.Resolver and reports per-resolver convergence so the
// operator can see the gTLD 48h NS TTL window settle.
rg.Get("/api/v1/sovereign/parent-domains", h.ListParentDomains)
rg.Post("/api/v1/sovereign/parent-domains", h.AddParentDomain)
rg.Delete("/api/v1/sovereign/parent-domains/{name}", h.DeleteParentDomain)
rg.Get("/api/v1/sovereign/parent-domains/{name}/propagation", h.GetPropagation)
})
log.Info("catalyst api listening", "port", port)

View File

@ -0,0 +1,792 @@
// parent_domains.go — admin-console "add another parent domain" flow +
// live DNS propagation status panel (issue #829, parent epic #825).
//
// Background — multi-domain Sovereign (epic #825)
// ------------------------------------------------
// A franchised Sovereign supports N parent domains, not 1. The operator
// brings:
// - the primary domain serving console.<primary>, api.<primary>, etc.
// - zero-or-more "sme-pool" domains offered to SME tenants for free
// subdomain allocation
// A post-handover surface in the Sovereign Console lets the operator add
// MORE parent domains over time (e.g. acquired a new portfolio domain).
//
// This file owns the four endpoints for that admin surface:
//
// GET /api/v1/sovereign/parent-domains — list current pool
// POST /api/v1/sovereign/parent-domains — add a new domain
// DELETE /api/v1/sovereign/parent-domains/{name} — remove a domain
// GET /api/v1/sovereign/parent-domains/{name}/propagation
// — per-resolver
// NS-flip propagation
//
// Sister tickets:
// - #826 (MD-1): Sovereign data model `parentDomains[]` + provisioning
// NS-flip loop. NOT YET MERGED → this file stubs the persistence
// layer with an in-memory store rooted on the existing single
// `SovereignFQDN` field as the implicit "primary" entry. When #826
// lands the AddParentDomain handler will switch from the in-memory
// store to writing into Deployment.parentDomains[] and triggering
// the same NS-flip loop the wizard fires at signup.
// - #827 (MD-2): PowerDNS multi-zone bootstrap + cert-manager per-zone
// wildcard cert. NOT YET MERGED → AddParentDomain emits an SSE-style
// log line for the zone-create + cert-issue steps so the UI can
// surface them, but does not block on actual reconciliation.
//
// Per docs/INVIOLABLE-PRINCIPLES.md:
// #1 (waterfall, target-state shape): the wire shape this file emits is
// the final shape — `parentDomains[]` with role + flipStatus +
// perResolverPropagation. It will not change when #826/#827 merge;
// only the persistence backing changes.
// #4 (never hardcode): the resolver list lives in `defaultResolvers`,
// overridable via `CATALYST_DNS_PROPAGATION_RESOLVERS` env. The
// per-query timeout + poll-rate are also env-tunable.
// #10 (credential hygiene): registrar API credentials submitted in the
// AddParentDomain POST are forwarded byte-for-byte to PDM via the
// existing /set-ns proxy seam in registrar.go — they never enter a
// struct that gets logged.
//
// Implementation note for the propagation panel:
// Go's net.Resolver supports custom Dial that lets us route NS lookups
// through a SPECIFIC resolver IP (8.8.8.8, 1.1.1.1, etc) rather than
// the system resolv.conf. We spin one goroutine per resolver, run
// LookupNS with a 5s deadline, and aggregate the results. The polling
// rate-limit lives client-side: the UI polls this endpoint every 60s,
// which is plenty given DNS gTLD TTL is 48h. The endpoint itself is
// stateless — every request fans out fresh.
package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
// ParentDomainRole names the two purposes a parent domain can serve in
// the Sovereign's pool. Mirrors the canonical shape from #826.
type ParentDomainRole string
const (
// RolePrimary — the operator's own domain (console.<name>,
// api.<name>, marketplace.<name>). Exactly one per Sovereign.
RolePrimary ParentDomainRole = "primary"
// RoleSMEPool — offered to SME tenants for free-subdomain
// allocation (e.g. console.acme.<name>). Zero-or-more per Sovereign.
RoleSMEPool ParentDomainRole = "sme-pool"
)
// FlipStatus — high-level state of the NS-flip + zone-create + cert
// pipeline for a single parent domain. Consumed by the admin UI to
// render per-row badges.
type FlipStatus string
const (
FlipStatusQueued FlipStatus = "queued"
FlipStatusFlipping FlipStatus = "flipping"
FlipStatusFlipped FlipStatus = "flipped"
FlipStatusFailed FlipStatus = "failed"
FlipStatusZoneCreate FlipStatus = "zone-creating"
FlipStatusCertIssue FlipStatus = "cert-issuing"
FlipStatusReady FlipStatus = "ready"
)
// ParentDomain is the wire-shape entry the admin surface renders + that
// AddParentDomain accepts (minus the credentials, which travel separately).
//
// Per #826 this will eventually also live on Deployment.parentDomains[];
// for now we serve it from the in-memory parentDomainStore below plus
// an implicit "primary" row synthesised from Deployment.SovereignFQDN.
type ParentDomain struct {
Name string `json:"name"`
Role ParentDomainRole `json:"role"`
RegistrarKind string `json:"registrarKind,omitempty"`
RegistrarCredsRef string `json:"registrarCredsRef,omitempty"`
FlipStatus FlipStatus `json:"flipStatus"`
FlipMessage string `json:"flipMessage,omitempty"`
AddedAt time.Time `json:"addedAt"`
FlippedAt *time.Time `json:"flippedAt,omitempty"`
}
// parentDomainStore — in-memory persistence for additions made via the
// admin surface. Backed by a sync.Map keyed by domain name. When #826
// lands this is replaced by Deployment.parentDomains[] + the store.go
// flat-file persistence layer; the wire shape stays identical so the UI
// is unaffected by the swap.
type parentDomainStore struct {
entries sync.Map // map[string]*ParentDomain
}
// global single-instance store. The handler reads this lazily so tests
// that build Handler{} directly still work — no Init wiring needed.
var globalParentDomainStore = &parentDomainStore{}
// list — snapshot of every entry, sorted by name for stable UI rendering.
func (s *parentDomainStore) list() []ParentDomain {
out := []ParentDomain{}
s.entries.Range(func(_, v any) bool {
out = append(out, *(v.(*ParentDomain)))
return true
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (s *parentDomainStore) get(name string) (*ParentDomain, bool) {
v, ok := s.entries.Load(strings.ToLower(name))
if !ok {
return nil, false
}
return v.(*ParentDomain), true
}
func (s *parentDomainStore) put(pd *ParentDomain) {
s.entries.Store(strings.ToLower(pd.Name), pd)
}
func (s *parentDomainStore) del(name string) bool {
_, loaded := s.entries.LoadAndDelete(strings.ToLower(name))
return loaded
}
// addParentDomainRequest — POST body shape. RegistrarToken is the
// secret-bearing field; like registrar.go we never log it.
type addParentDomainRequest struct {
Name string `json:"name"`
Role string `json:"role"`
RegistrarKind string `json:"registrarKind"`
RegistrarToken string `json:"registrarToken"`
}
// validateDomainName rejects obvious malformed inputs early. Production
// guards live PDM-side; this is a quick FE-feedback layer only.
func validateDomainName(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("missing-name")
}
if len(name) > 253 {
return fmt.Errorf("name-too-long")
}
if strings.Contains(name, "/") || strings.Contains(name, " ") {
return fmt.Errorf("invalid-name")
}
if !strings.Contains(name, ".") {
return fmt.Errorf("not-fqdn")
}
return nil
}
// ListParentDomains handles GET /api/v1/sovereign/parent-domains.
//
// The shape is `{"items": [...]}` to match the rest of the catalyst-api
// list endpoints (UserAccess, deployments, etc).
func (h *Handler) ListParentDomains(w http.ResponseWriter, r *http.Request) {
items := globalParentDomainStore.list()
// Synthesise the implicit "primary" row from any deployment record
// that has been adopted (i.e. the operator has finalised handover
// and is now using this Sovereign as the home cluster). This is the
// stub stand-in for #826's Deployment.parentDomains[].
primaryName := h.lookupPrimaryDomain()
if primaryName != "" {
// Avoid duplicating if the operator already added their primary
// via the admin UI (idempotency).
alreadyListed := false
for _, it := range items {
if strings.EqualFold(it.Name, primaryName) {
alreadyListed = true
break
}
}
if !alreadyListed {
items = append([]ParentDomain{{
Name: primaryName,
Role: RolePrimary,
FlipStatus: FlipStatusReady,
AddedAt: time.Time{},
}}, items...)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
})
}
// lookupPrimaryDomain returns the SovereignFQDN of any adopted deployment
// (i.e. the operator has finalised handover). For the Catalyst-Zero case
// where no deployment has been adopted yet, returns the empty string.
//
// Best-effort: when multiple adopted deployments exist (rare — typically
// only the home Sovereign reaches that state), returns the lexically
// first one for determinism.
func (h *Handler) lookupPrimaryDomain() string {
var candidates []string
h.deployments.Range(func(_, v any) bool {
dep, ok := v.(*Deployment)
if !ok {
return true
}
dep.mu.Lock()
fqdn := strings.TrimSpace(dep.Request.SovereignFQDN)
adopted := dep.AdoptedAt != nil
dep.mu.Unlock()
if fqdn != "" && adopted {
candidates = append(candidates, fqdn)
}
return true
})
if len(candidates) == 0 {
// Fallback: env override for tests / single-Sovereign sandboxes.
if v := strings.TrimSpace(os.Getenv("CATALYST_PRIMARY_DOMAIN")); v != "" {
return v
}
return ""
}
sort.Strings(candidates)
return candidates[0]
}
// AddParentDomain handles POST /api/v1/sovereign/parent-domains.
//
// Pipeline (sequential, so the UI can render a per-step progress bar):
// 1. Validate the request body (name shape, role enum, creds present).
// 2. Insert a `flipping` row into the store (or 409 if it already
// exists) so a concurrent GET/list reflects the in-flight state.
// 3. Forward the credentials to PDM's /set-ns endpoint to actually
// flip the NS records at the registrar (#826's real engine).
// 4. Forward to PDM's /zones endpoint to bootstrap the PowerDNS zone
// (#827, currently a stub since #827 hasn't merged).
// 5. Update the store row to `ready` (or `failed` with detail).
//
// All three external calls are bounded by a per-call context with the
// request context as parent so a client cancel propagates.
func (h *Handler) AddParentDomain(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<14))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "request-too-large",
"detail": "body must be under 16KB",
})
return
}
var req addParentDomainRequest
if err := json.Unmarshal(raw, &req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid-body",
"detail": "body must be JSON {name, role, registrarKind, registrarToken}",
})
return
}
if err := validateDomainName(req.Name); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": "invalid-name",
"detail": err.Error(),
})
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role == "" {
role = string(RoleSMEPool)
}
if role != string(RolePrimary) && role != string(RoleSMEPool) {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": "invalid-role",
"detail": "role must be 'primary' or 'sme-pool'",
})
return
}
if strings.TrimSpace(req.RegistrarKind) == "" {
req.RegistrarKind = "dynadot"
}
if _, ok := supportedRegistrars[strings.ToLower(req.RegistrarKind)]; !ok {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": "unsupported-registrar",
"detail": fmt.Sprintf("registrar %q not supported", req.RegistrarKind),
})
return
}
if strings.TrimSpace(req.RegistrarToken) == "" {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": "missing-token",
"detail": "registrarToken is required",
})
return
}
// Idempotency: a second POST for the same name returns 409 instead
// of double-flipping (which would cost real $$$ at the registrar
// per-call billing tier of some adapters).
name := strings.ToLower(strings.TrimSpace(req.Name))
if _, exists := globalParentDomainStore.get(name); exists {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "already-exists",
"detail": "parent domain already in pool",
})
return
}
// Refuse to add the operator's primary as an additional row; the
// primary is implicit from Deployment.SovereignFQDN.
if primary := h.lookupPrimaryDomain(); primary != "" && strings.EqualFold(primary, name) {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "primary-already-set",
"detail": "domain is the Sovereign's primary; already in pool",
})
return
}
now := time.Now().UTC()
pd := &ParentDomain{
Name: name,
Role: ParentDomainRole(role),
RegistrarKind: strings.ToLower(req.RegistrarKind),
FlipStatus: FlipStatusFlipping,
AddedAt: now,
}
globalParentDomainStore.put(pd)
// Step 1 of #826: NS-flip via PDM proxy. Forward credentials
// byte-for-byte; never log the token.
flipErr := h.pdmFlipNS(r.Context(), req.RegistrarKind, req.Name, req.RegistrarToken)
if flipErr != nil {
pd.FlipStatus = FlipStatusFailed
pd.FlipMessage = "ns-flip: " + flipErr.Error()
globalParentDomainStore.put(pd)
// Log without the token — only registrar + domain + status
h.log.Info("parent-domain ns-flip failed",
"registrar", req.RegistrarKind,
"domain", req.Name,
"err", flipErr.Error(),
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "ns-flip-failed",
"detail": flipErr.Error(),
"item": pd,
})
return
}
pd.FlipStatus = FlipStatusZoneCreate
globalParentDomainStore.put(pd)
// Step 2 of #827: PowerDNS zone create. Best-effort stub — when
// #827 lands this becomes a hard dependency.
zoneErr := h.pdmCreatePowerDNSZone(r.Context(), req.Name)
if zoneErr != nil {
pd.FlipStatus = FlipStatusFailed
pd.FlipMessage = "zone-create: " + zoneErr.Error()
globalParentDomainStore.put(pd)
h.log.Info("parent-domain zone-create failed",
"domain", req.Name,
"err", zoneErr.Error(),
)
// We don't roll back the registrar NS-flip — that's a deliberate
// no-op since the gTLD TTL of 48h means a flip-then-flip-back
// burns 4 days for the same end-state. Operator can retry the
// zone-create + cert step independently.
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "zone-create-failed",
"detail": zoneErr.Error(),
"item": pd,
})
return
}
pd.FlipStatus = FlipStatusCertIssue
globalParentDomainStore.put(pd)
// Step 3 of #827: cert-manager wildcard Certificate create. Stub.
// When #827 lands this writes a Certificate CR to the cluster via
// dynamic client.
certErr := h.createWildcardCert(r.Context(), req.Name)
if certErr != nil {
pd.FlipStatus = FlipStatusFailed
pd.FlipMessage = "cert-issue: " + certErr.Error()
globalParentDomainStore.put(pd)
h.log.Info("parent-domain cert-issue failed",
"domain", req.Name,
"err", certErr.Error(),
)
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "cert-issue-failed",
"detail": certErr.Error(),
"item": pd,
})
return
}
flippedAt := time.Now().UTC()
pd.FlipStatus = FlipStatusReady
pd.FlippedAt = &flippedAt
globalParentDomainStore.put(pd)
h.log.Info("parent-domain added",
"registrar", req.RegistrarKind,
"domain", req.Name,
"role", role,
)
writeJSON(w, http.StatusCreated, pd)
}
// DeleteParentDomain handles DELETE /api/v1/sovereign/parent-domains/{name}.
//
// The handler removes the row from the pool but does NOT un-flip the
// registrar NS records — that's a destructive operation an operator
// should perform deliberately at their registrar UI. The intent here is
// "stop offering this domain to SMEs"; the gTLD NS delegation can stay.
func (h *Handler) DeleteParentDomain(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "name")))
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing-name"})
return
}
if primary := h.lookupPrimaryDomain(); primary != "" && strings.EqualFold(primary, name) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "primary-locked",
"detail": "cannot remove the Sovereign's primary domain",
})
return
}
if !globalParentDomainStore.del(name) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not-found"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── DNS propagation panel ───────────────────────────────────────────────
// defaultResolvers — public DNS resolvers we query the parent zone's NS
// records against. The five chosen here cover the major recursive-resolver
// providers + a geographic spread:
//
// - 8.8.8.8 Google US
// - 1.1.1.1 Cloudflare global anycast
// - 9.9.9.9 Quad9 IBM/PCH (Switzerland-anchored)
// - 208.67.222.222 OpenDNS / Cisco US
// - 4.2.2.1 Level 3 / CenturyLink US (Asia presence)
//
// Per docs/INVIOLABLE-PRINCIPLES.md #4 this list is overridable via the
// `CATALYST_DNS_PROPAGATION_RESOLVERS` env var (comma-separated IPs).
// An air-gapped operator can swap to internal resolvers if their security
// posture forbids egress to public DNS.
var defaultResolvers = []resolverSpec{
{Name: "Google", IP: "8.8.8.8", Geo: "US"},
{Name: "Cloudflare", IP: "1.1.1.1", Geo: "Global"},
{Name: "Quad9", IP: "9.9.9.9", Geo: "EU"},
{Name: "OpenDNS", IP: "208.67.222.222", Geo: "US"},
{Name: "Level3", IP: "4.2.2.1", Geo: "US"},
}
type resolverSpec struct {
Name string `json:"name"`
IP string `json:"ip"`
Geo string `json:"geo"`
}
// PropagationState — per-resolver result that the UI renders as a
// green/yellow/red pill plus a tooltip.
type PropagationState struct {
Resolver resolverSpec `json:"resolver"`
Status string `json:"status"` // converged | diverged | error
NS []string `json:"ns"` // NS records returned (sorted)
QueriedAt time.Time `json:"queriedAt"`
LatencyMs int64 `json:"latencyMs"`
Err string `json:"error,omitempty"`
}
// PropagationResponse — full payload returned by GET /propagation.
type PropagationResponse struct {
Domain string `json:"domain"`
ExpectedNS []string `json:"expectedNs"`
Resolvers []PropagationState `json:"resolvers"`
Converged int `json:"converged"`
Total int `json:"total"`
Percentage int `json:"percentage"`
GeneratedAt time.Time `json:"generatedAt"`
}
// resolversFromEnv parses CATALYST_DNS_PROPAGATION_RESOLVERS into a
// resolverSpec slice. Empty / unset → defaultResolvers.
func resolversFromEnv() []resolverSpec {
raw := strings.TrimSpace(os.Getenv("CATALYST_DNS_PROPAGATION_RESOLVERS"))
if raw == "" {
return defaultResolvers
}
out := []resolverSpec{}
for _, part := range strings.Split(raw, ",") {
ip := strings.TrimSpace(part)
if ip == "" {
continue
}
out = append(out, resolverSpec{Name: ip, IP: ip, Geo: "Custom"})
}
if len(out) == 0 {
return defaultResolvers
}
return out
}
// resolveQueryTimeout — bound on a single LookupNS call. Generous enough
// to absorb a slow public resolver, tight enough that a stuck resolver
// doesn't pin the whole panel.
const resolveQueryTimeout = 5 * time.Second
// lookupNSAt issues an authoritative-zone NS lookup against a SPECIFIC
// resolver IP using a custom net.Resolver Dial that ignores the system
// /etc/resolv.conf. Returns the sorted NS list or an error.
//
// Inviolable principle #4 satisfied: no resolver IP hardcoded — caller
// passes the IP from defaultResolvers / env override.
func lookupNSAt(ctx context.Context, resolverIP, domain string) ([]string, error) {
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: resolveQueryTimeout}
return d.DialContext(ctx, "udp", net.JoinHostPort(resolverIP, "53"))
},
}
queryCtx, cancel := context.WithTimeout(ctx, resolveQueryTimeout)
defer cancel()
nsRecs, err := r.LookupNS(queryCtx, domain)
if err != nil {
return nil, err
}
out := make([]string, 0, len(nsRecs))
for _, ns := range nsRecs {
out = append(out, strings.ToLower(strings.TrimSuffix(ns.Host, ".")))
}
sort.Strings(out)
return out, nil
}
// expectedNSFor returns the canonical Sovereign PowerDNS NS records for
// a given parent domain. The Sovereign's PowerDNS exposes itself as
// `ns1.<sovereign-fqdn>` + `ns2.<sovereign-fqdn>`; an operator with a
// non-default deployment can override via `CATALYST_EXPECTED_NS`
// (comma-separated host list) per inviolable principle #4.
func (h *Handler) expectedNSFor(_ string) []string {
if raw := strings.TrimSpace(os.Getenv("CATALYST_EXPECTED_NS")); raw != "" {
out := []string{}
for _, part := range strings.Split(raw, ",") {
s := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(part), "."))
if s != "" {
out = append(out, s)
}
}
sort.Strings(out)
return out
}
primary := h.lookupPrimaryDomain()
if primary == "" {
return nil
}
out := []string{
"ns1." + primary,
"ns2." + primary,
}
sort.Strings(out)
return out
}
// nsSetsMatch returns true when `got` contains at least one entry from
// `expected`. The match is intentionally weak — most public resolvers
// will return the SOA/NS list verbatim from the parent zone, but some
// glue records may add CNAME-flattened intermediaries; a single
// expected NS landing in the result is sufficient evidence of flip
// convergence.
func nsSetsMatch(got, expected []string) bool {
if len(expected) == 0 {
return false
}
seen := map[string]struct{}{}
for _, g := range got {
seen[strings.ToLower(strings.TrimSuffix(g, "."))] = struct{}{}
}
for _, e := range expected {
if _, ok := seen[strings.ToLower(strings.TrimSuffix(e, "."))]; ok {
return true
}
}
return false
}
// GetPropagation handles GET /api/v1/sovereign/parent-domains/{name}/propagation.
//
// Fans out one goroutine per configured resolver, aggregates results,
// returns the PropagationResponse. Total wall-clock time is bounded by
// `resolveQueryTimeout` (the slowest resolver), not by the sum.
func (h *Handler) GetPropagation(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "name")))
if err := validateDomainName(name); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
"error": "invalid-name",
"detail": err.Error(),
})
return
}
resolvers := resolversFromEnv()
expected := h.expectedNSFor(name)
results := make([]PropagationState, len(resolvers))
var wg sync.WaitGroup
for i, spec := range resolvers {
wg.Add(1)
go func(idx int, s resolverSpec) {
defer wg.Done()
start := time.Now()
ns, err := lookupNSAt(r.Context(), s.IP, name)
latency := time.Since(start).Milliseconds()
ps := PropagationState{
Resolver: s,
NS: ns,
QueriedAt: time.Now().UTC(),
LatencyMs: latency,
}
switch {
case err != nil:
ps.Status = "error"
ps.Err = err.Error()
case nsSetsMatch(ns, expected):
ps.Status = "converged"
default:
ps.Status = "diverged"
}
results[idx] = ps
}(i, spec)
}
wg.Wait()
converged := 0
for _, r := range results {
if r.Status == "converged" {
converged++
}
}
total := len(results)
pct := 0
if total > 0 {
pct = converged * 100 / total
}
writeJSON(w, http.StatusOK, PropagationResponse{
Domain: name,
ExpectedNS: expected,
Resolvers: results,
Converged: converged,
Total: total,
Percentage: pct,
GeneratedAt: time.Now().UTC(),
})
}
// ── PDM proxy helpers (registrar NS-flip + zone create) ─────────────────
// pdmFlipNS forwards the registrar credentials to PDM's /set-ns endpoint.
// Same wire shape as registrar.go's SetNSRegistrar; we re-implement the
// call here (rather than re-using SetNSRegistrar's HTTP handler) so the
// AddParentDomain pipeline can examine the response and update the store
// atomically. Token never enters a logged struct.
func (h *Handler) pdmFlipNS(ctx context.Context, registrarKind, domain, token string) error {
pdmBase := pdmBaseURL()
if pdmBase == "" {
return fmt.Errorf("pdm-unavailable")
}
body, _ := json.Marshal(map[string]string{
"domain": domain,
"token": token,
})
target := fmt.Sprintf("%s/api/v1/registrar/%s/set-ns",
strings.TrimRight(pdmBase, "/"),
strings.ToLower(registrarKind),
)
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("build-request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(httpReq)
if err != nil {
return fmt.Errorf("pdm-unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("pdm-status-%d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
// pdmCreatePowerDNSZone — stub for #827. When #827 lands this calls
// PDM's (or directly the Sovereign-cluster's) PowerDNS API to bootstrap
// a new zone. Until then we return nil so AddParentDomain proceeds; the
// admin UI surfaces "zone-creating" → "ready" against the stub but no
// real zone is created.
//
// Production ENV trigger: when CATALYST_PDM_ZONES_ENABLED=true the call
// fires for real; otherwise the stub no-op is returned. This way #829
// can ship before #827 without baking a known-broken call into the
// hot path.
func (h *Handler) pdmCreatePowerDNSZone(ctx context.Context, domain string) error {
if os.Getenv("CATALYST_PDM_ZONES_ENABLED") != "true" {
// Stub path — log so an operator can see we'd have called PDM.
h.log.Info("parent-domain zone-create: stub (CATALYST_PDM_ZONES_ENABLED not set)",
"domain", domain,
)
return nil
}
pdmBase := pdmBaseURL()
if pdmBase == "" {
return fmt.Errorf("pdm-unavailable")
}
body, _ := json.Marshal(map[string]string{
"name": domain,
"kind": "Native",
})
target := fmt.Sprintf("%s/api/v1/zones",
strings.TrimRight(pdmBase, "/"),
)
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("build-request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(httpReq)
if err != nil {
return fmt.Errorf("pdm-unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode != http.StatusConflict {
// 409 == idempotent "already exists", treat as success.
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("pdm-status-%d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return nil
}
// createWildcardCert — stub for #827. When #827 lands this writes a
// `cert-manager.io/v1` Certificate to the home cluster requesting
// `*.<domain>` + `<domain>` via the existing PowerDNS-webhook DNS-01
// solver. Until then we return nil so AddParentDomain proceeds.
func (h *Handler) createWildcardCert(_ context.Context, domain string) error {
if os.Getenv("CATALYST_CERTIFICATE_AUTO_CREATE") != "true" {
h.log.Info("parent-domain cert-issue: stub (CATALYST_CERTIFICATE_AUTO_CREATE not set)",
"domain", domain,
)
return nil
}
// Real implementation deferred to #827 sister PR.
return nil
}

View File

@ -0,0 +1,366 @@
// parent_domains_test.go — coverage for the admin parent-domain pool
// surface (issue #829).
//
// The HTTP handlers are exercised end-to-end against a stub PDM (no
// network egress, no real registrar API). The propagation panel is
// covered by a unit test against `nsSetsMatch` + `lookupNSAt` (the
// latter requires network egress so it is gated behind a build tag in
// CI; the unit-test path here covers the wire shape only).
package handler
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
)
// resetParentDomainStore is a per-test hygiene helper — the global
// store is shared across handlers so test isolation needs an explicit
// teardown.
func resetParentDomainStore() {
globalParentDomainStore = &parentDomainStore{entries: sync.Map{}}
}
func newParentDomainsRouter(h *Handler) *chi.Mux {
r := chi.NewRouter()
r.Get("/api/v1/sovereign/parent-domains", h.ListParentDomains)
r.Post("/api/v1/sovereign/parent-domains", h.AddParentDomain)
r.Delete("/api/v1/sovereign/parent-domains/{name}", h.DeleteParentDomain)
r.Get("/api/v1/sovereign/parent-domains/{name}/propagation", h.GetPropagation)
return r
}
func TestListParentDomains_EmptyPool(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "")
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/sovereign/parent-domains", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Items []ParentDomain `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Items) != 0 {
t.Fatalf("want empty pool, got %d items", len(resp.Items))
}
}
func TestListParentDomains_PrimaryFromEnv(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "omani.works")
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/sovereign/parent-domains", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
var resp struct {
Items []ParentDomain `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Items) != 1 || resp.Items[0].Name != "omani.works" || resp.Items[0].Role != RolePrimary {
t.Fatalf("expected synthesised primary row, got %+v", resp.Items)
}
if resp.Items[0].FlipStatus != FlipStatusReady {
t.Fatalf("expected primary FlipStatus=ready, got %s", resp.Items[0].FlipStatus)
}
}
func TestAddParentDomain_ValidationErrors(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "")
h := &Handler{log: slog.Default()}
cases := []struct {
name string
body string
wantHTTP int
wantErr string
}{
{
name: "missing name",
body: `{"name":"","registrarKind":"dynadot","registrarToken":"x"}`,
wantHTTP: http.StatusUnprocessableEntity,
wantErr: "invalid-name",
},
{
name: "not fqdn",
body: `{"name":"foo","registrarKind":"dynadot","registrarToken":"x"}`,
wantHTTP: http.StatusUnprocessableEntity,
wantErr: "invalid-name",
},
{
name: "missing token",
body: `{"name":"omani.works","registrarKind":"dynadot","registrarToken":""}`,
wantHTTP: http.StatusUnprocessableEntity,
wantErr: "missing-token",
},
{
name: "invalid role",
body: `{"name":"omani.works","role":"bogus","registrarKind":"dynadot","registrarToken":"x"}`,
wantHTTP: http.StatusUnprocessableEntity,
wantErr: "invalid-role",
},
{
name: "unsupported registrar",
body: `{"name":"omani.works","role":"sme-pool","registrarKind":"bogus","registrarToken":"x"}`,
wantHTTP: http.StatusUnprocessableEntity,
wantErr: "unsupported-registrar",
},
{
name: "garbage body",
body: `not json`,
wantHTTP: http.StatusBadRequest,
wantErr: "invalid-body",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/sovereign/parent-domains",
bytes.NewBufferString(tc.body))
req.Header.Set("Content-Type", "application/json")
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != tc.wantHTTP {
t.Fatalf("want %d, got %d body=%s", tc.wantHTTP, rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(tc.wantErr)) {
t.Fatalf("want body to mention %q, got %s", tc.wantErr, rec.Body.String())
}
})
}
}
func TestAddParentDomain_DuplicateConflict(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "")
// Stub PDM that always 200s for /set-ns, so the first add succeeds.
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/set-ns") {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"valid":true}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer stub.Close()
t.Setenv("POOL_DOMAIN_MANAGER_URL", stub.URL)
h := &Handler{log: slog.Default()}
body := `{"name":"omani.trade","role":"sme-pool","registrarKind":"dynadot","registrarToken":"abc"}`
first := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/sovereign/parent-domains", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
newParentDomainsRouter(h).ServeHTTP(first, req)
if first.Code != http.StatusCreated {
t.Fatalf("first add: want 201, got %d body=%s", first.Code, first.Body.String())
}
// Second POST with the same name → 409.
second := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/sovereign/parent-domains", bytes.NewBufferString(body))
req2.Header.Set("Content-Type", "application/json")
newParentDomainsRouter(h).ServeHTTP(second, req2)
if second.Code != http.StatusConflict {
t.Fatalf("second add: want 409, got %d", second.Code)
}
}
func TestAddParentDomain_PDMSetNSFail(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "")
// Stub PDM that 502s the /set-ns call so we exercise the failure path.
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/set-ns") {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":"upstream-rejected"}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer stub.Close()
t.Setenv("POOL_DOMAIN_MANAGER_URL", stub.URL)
h := &Handler{log: slog.Default()}
body := `{"name":"oman.tel","role":"sme-pool","registrarKind":"dynadot","registrarToken":"abc"}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/sovereign/parent-domains", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("want 502, got %d body=%s", rec.Code, rec.Body.String())
}
// Row should now exist with status=failed.
pd, ok := globalParentDomainStore.get("oman.tel")
if !ok {
t.Fatal("row should be persisted with failed status")
}
if pd.FlipStatus != FlipStatusFailed {
t.Fatalf("want FlipStatus=failed, got %s", pd.FlipStatus)
}
}
func TestDeleteParentDomain_Removes(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "")
globalParentDomainStore.put(&ParentDomain{
Name: "omani.trade",
Role: RoleSMEPool,
FlipStatus: FlipStatusReady,
})
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/sovereign/parent-domains/omani.trade", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204, got %d", rec.Code)
}
if _, ok := globalParentDomainStore.get("omani.trade"); ok {
t.Fatal("row should be deleted")
}
}
func TestDeleteParentDomain_PrimaryLocked(t *testing.T) {
resetParentDomainStore()
t.Setenv("CATALYST_PRIMARY_DOMAIN", "omani.works")
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/sovereign/parent-domains/omani.works", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestNSSetsMatch(t *testing.T) {
cases := []struct {
name string
got []string
expected []string
want bool
}{
{"empty expected", []string{"a"}, nil, false},
{"perfect match", []string{"ns1.x.y", "ns2.x.y"}, []string{"ns1.x.y", "ns2.x.y"}, true},
{"partial overlap", []string{"ns1.x.y", "ns2.foo"}, []string{"ns1.x.y", "ns2.x.y"}, true},
{"no overlap", []string{"ns1.foo", "ns2.foo"}, []string{"ns1.x.y", "ns2.x.y"}, false},
{"trailing dot tolerant", []string{"ns1.x.y."}, []string{"ns1.x.y"}, true},
{"case insensitive", []string{"NS1.X.Y"}, []string{"ns1.x.y"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nsSetsMatch(tc.got, tc.expected); got != tc.want {
t.Fatalf("nsSetsMatch(%v, %v) = %v, want %v", tc.got, tc.expected, got, tc.want)
}
})
}
}
func TestResolversFromEnv_Default(t *testing.T) {
t.Setenv("CATALYST_DNS_PROPAGATION_RESOLVERS", "")
got := resolversFromEnv()
if len(got) < 5 {
t.Fatalf("expected ≥5 default resolvers, got %d", len(got))
}
}
func TestResolversFromEnv_Override(t *testing.T) {
t.Setenv("CATALYST_DNS_PROPAGATION_RESOLVERS", "10.0.0.1, 10.0.0.2,, 10.0.0.3")
got := resolversFromEnv()
if len(got) != 3 {
t.Fatalf("expected 3 resolvers from override, got %d", len(got))
}
if got[0].IP != "10.0.0.1" || got[2].IP != "10.0.0.3" {
t.Fatalf("override parse wrong: %+v", got)
}
}
func TestExpectedNS_FromEnvOverride(t *testing.T) {
t.Setenv("CATALYST_EXPECTED_NS", "ns1.foo.bar, ns2.foo.bar.")
h := &Handler{log: slog.Default()}
got := h.expectedNSFor("any.domain")
if len(got) != 2 || got[0] != "ns1.foo.bar" || got[1] != "ns2.foo.bar" {
t.Fatalf("env override parse wrong: %v", got)
}
}
func TestExpectedNS_DerivedFromPrimary(t *testing.T) {
t.Setenv("CATALYST_EXPECTED_NS", "")
t.Setenv("CATALYST_PRIMARY_DOMAIN", "omani.works")
h := &Handler{log: slog.Default()}
got := h.expectedNSFor("any.domain")
if len(got) != 2 || got[0] != "ns1.omani.works" || got[1] != "ns2.omani.works" {
t.Fatalf("derived NS wrong: %v", got)
}
}
func TestGetPropagation_InvalidName(t *testing.T) {
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/sovereign/parent-domains/foo/propagation", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 for non-FQDN, got %d", rec.Code)
}
}
func TestGetPropagation_ResponseShape(t *testing.T) {
// Restrict to a single non-resolvable IP so the test is fast +
// deterministic. The 192.0.2.0/24 block is RFC 5737 TEST-NET-1 —
// guaranteed to never route to a real DNS server.
t.Setenv("CATALYST_DNS_PROPAGATION_RESOLVERS", "192.0.2.1")
t.Setenv("CATALYST_EXPECTED_NS", "ns1.example.com,ns2.example.com")
h := &Handler{log: slog.Default()}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/sovereign/parent-domains/example.com/propagation", nil)
newParentDomainsRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d body=%s", rec.Code, rec.Body.String())
}
body, _ := io.ReadAll(rec.Body)
var resp PropagationResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatal(err)
}
if resp.Domain != "example.com" {
t.Fatalf("domain wrong: %s", resp.Domain)
}
if resp.Total != 1 || len(resp.Resolvers) != 1 {
t.Fatalf("expected 1 resolver, got total=%d resolvers=%d", resp.Total, len(resp.Resolvers))
}
// Status must be 'error' since 192.0.2.1 doesn't answer.
if resp.Resolvers[0].Status != "error" {
t.Fatalf("expected status=error for blackhole resolver, got %s", resp.Resolvers[0].Status)
}
if len(resp.ExpectedNS) != 2 {
t.Fatalf("expected 2 NS records, got %d", len(resp.ExpectedNS))
}
if resp.Percentage != 0 {
t.Fatalf("expected 0%% propagated when all errored, got %d", resp.Percentage)
}
}

View File

@ -0,0 +1,301 @@
/**
* parent-domains-829.spec.ts Playwright E2E for the admin
* Parent Domains page + DNS propagation status panel (issue #829,
* parent epic #825).
*
* Three canonical screenshots captured at 1440x900 per the
* `feedback_parallel_agents_e2e` rule in CLAUDE.md:
*
* 1. just-flipped single-row pool, all resolvers reporting
* "diverged" (NS-flip not yet propagated)
* 2. partially-propagated 2 of 5 resolvers converged, 60% pending
* 3. fully-propagated all 5 resolvers converged, 100%
*
* The page is normally served behind the Sovereign-mode auth gate;
* this spec routes the API responses via Playwright's network
* intercept so the same React tree renders without a live backend.
*/
import { test, expect, type Page } from '@playwright/test'
const VIEWPORT = { width: 1440, height: 900 }
const RESOLVERS = [
{ name: 'Google', ip: '8.8.8.8', geo: 'US' },
{ name: 'Cloudflare', ip: '1.1.1.1', geo: 'Global' },
{ name: 'Quad9', ip: '9.9.9.9', geo: 'EU' },
{ name: 'OpenDNS', ip: '208.67.222.222', geo: 'US' },
{ name: 'Level3', ip: '4.2.2.1', geo: 'US' },
]
const EXPECTED_NS = ['ns1.omani.works', 'ns2.omani.works']
/**
* Mounts the ParentDomainsPage in isolation the sovereign-mode auth
* shell needs OIDC tokens which we don't have in this spec. We use the
* dev server's hash-router-friendly seam (the page is registered at
* /console/parent-domains; we navigate through to it directly by setting
* the test fixtures via API mocks.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), the URL is
* resolved at runtime from the page's own routing (no inline /api/...).
*/
async function mountAdminPage(
page: Page,
domains: Array<{
name: string
role: 'primary' | 'sme-pool'
flipStatus: string
addedAt: string
flippedAt?: string
registrarKind?: string
}>,
propagation: {
domain: string
expectedNs: string[]
converged: number
total: number
percentage: number
resolvers: Array<{
resolver: { name: string; ip: string; geo: string }
status: 'converged' | 'diverged' | 'error'
ns: string[]
queriedAt: string
latencyMs: number
error?: string
}>
},
) {
// Bypass the SovereignConsoleLayout auth gate: it calls /whoami first,
// and falls back to OIDC redirect on 401. A 200 here lets the layout
// render directly without touching Keycloak.
await page.route('**/api/v1/whoami', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sub: 'test-operator',
name: 'Test Operator',
email: 'operator@example.com',
preferred_username: 'operator',
roles: ['operator-admin'],
}),
})
})
// Mock the LIST + propagation endpoints so the page renders against
// deterministic state.
await page.route('**/api/v1/sovereign/parent-domains', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: domains }),
})
return
}
await route.continue()
})
await page.route('**/api/v1/sovereign/parent-domains/*/propagation', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...propagation,
generatedAt: new Date().toISOString(),
}),
})
})
// Bypass the OIDC tokens-required gate: the SovereignConsoleLayout
// only checks for `tokens` in localStorage. Plant a synthetic value
// so the layout renders without the post-callback redirect.
await page.addInitScript(() => {
const stub = {
idToken:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.x',
accessToken: 'stub',
refreshToken: 'stub',
expiresAt: Date.now() + 3600 * 1000,
}
localStorage.setItem('catalyst.oidc.tokens', JSON.stringify(stub))
})
await page.goto('/console/parent-domains')
await page.waitForSelector('[data-testid=parent-domains-page]', { timeout: 5_000 })
}
test.use({ viewport: VIEWPORT })
test.describe('ParentDomainsPage @issue-829', () => {
test('1. just-flipped — single-row pool, all resolvers diverged', async ({ page }) => {
await mountAdminPage(
page,
[
{
name: 'omani.works',
role: 'primary',
flipStatus: 'ready',
addedAt: '2026-05-04T08:00:00Z',
flippedAt: '2026-05-04T08:30:00Z',
},
{
name: 'omani.trade',
role: 'sme-pool',
flipStatus: 'flipping',
registrarKind: 'dynadot',
addedAt: new Date().toISOString(),
},
],
{
domain: 'omani.trade',
expectedNs: EXPECTED_NS,
converged: 0,
total: 5,
percentage: 0,
resolvers: RESOLVERS.map((r) => ({
resolver: r,
status: 'diverged' as const,
ns: ['ns1.dynadot.com', 'ns2.dynadot.com'],
queriedAt: new Date().toISOString(),
latencyMs: 42 + Math.floor(Math.random() * 30),
})),
},
)
// Expand the omani.trade row to show the propagation panel.
await page.getByTestId('parent-domain-toggle-omani.trade').click()
await page.waitForSelector('[data-testid="propagation-panel-omani.trade"]')
expect(await page.getByTestId('parent-domains-table').isVisible()).toBe(true)
const pct = await page.getByTestId('propagation-pct-omani.trade').textContent()
expect(pct).toContain('0%')
await page.screenshot({
path: 'e2e/screenshots/829-1-just-flipped.png',
fullPage: false,
})
})
test('2. partially-propagated — 2 of 5 resolvers converged', async ({ page }) => {
await mountAdminPage(
page,
[
{
name: 'omani.works',
role: 'primary',
flipStatus: 'ready',
addedAt: '2026-05-04T08:00:00Z',
},
{
name: 'omani.trade',
role: 'sme-pool',
flipStatus: 'cert-issuing',
registrarKind: 'dynadot',
addedAt: '2026-05-04T09:00:00Z',
},
],
{
domain: 'omani.trade',
expectedNs: EXPECTED_NS,
converged: 2,
total: 5,
percentage: 40,
resolvers: [
{
resolver: RESOLVERS[0],
status: 'converged',
ns: EXPECTED_NS,
queriedAt: new Date().toISOString(),
latencyMs: 23,
},
{
resolver: RESOLVERS[1],
status: 'converged',
ns: EXPECTED_NS,
queriedAt: new Date().toISOString(),
latencyMs: 18,
},
{
resolver: RESOLVERS[2],
status: 'diverged',
ns: ['ns1.dynadot.com', 'ns2.dynadot.com'],
queriedAt: new Date().toISOString(),
latencyMs: 67,
},
{
resolver: RESOLVERS[3],
status: 'diverged',
ns: ['ns1.dynadot.com', 'ns2.dynadot.com'],
queriedAt: new Date().toISOString(),
latencyMs: 89,
},
{
resolver: RESOLVERS[4],
status: 'diverged',
ns: ['ns1.dynadot.com', 'ns2.dynadot.com'],
queriedAt: new Date().toISOString(),
latencyMs: 102,
},
],
},
)
await page.getByTestId('parent-domain-toggle-omani.trade').click()
await page.waitForSelector('[data-testid="propagation-panel-omani.trade"]')
const pct = await page.getByTestId('propagation-pct-omani.trade').textContent()
expect(pct).toContain('40%')
await page.screenshot({
path: 'e2e/screenshots/829-2-partially-propagated.png',
fullPage: false,
})
})
test('3. fully-propagated — 100%', async ({ page }) => {
await mountAdminPage(
page,
[
{
name: 'omani.works',
role: 'primary',
flipStatus: 'ready',
addedAt: '2026-05-04T08:00:00Z',
flippedAt: '2026-05-04T08:30:00Z',
},
{
name: 'omani.trade',
role: 'sme-pool',
flipStatus: 'ready',
registrarKind: 'dynadot',
addedAt: '2026-05-04T09:00:00Z',
flippedAt: '2026-05-04T15:00:00Z',
},
],
{
domain: 'omani.trade',
expectedNs: EXPECTED_NS,
converged: 5,
total: 5,
percentage: 100,
resolvers: RESOLVERS.map((r, i) => ({
resolver: r,
status: 'converged' as const,
ns: EXPECTED_NS,
queriedAt: new Date().toISOString(),
latencyMs: 18 + i * 5,
})),
},
)
await page.getByTestId('parent-domain-toggle-omani.trade').click()
await page.waitForSelector('[data-testid="propagation-panel-omani.trade"]')
const pct = await page.getByTestId('propagation-pct-omani.trade').textContent()
expect(pct).toContain('100%')
await page.screenshot({
path: 'e2e/screenshots/829-3-fully-propagated.png',
fullPage: false,
})
})
})

View File

@ -66,6 +66,7 @@ import { CloudPage } from '@/pages/sovereign/CloudPage'
import { DecommissionPage } from '@/pages/sovereign/DecommissionPage'
import { UserAccessListPage } from '@/pages/admin/user-access/UserAccessListPage'
import { UserAccessEditPage } from '@/pages/admin/user-access/UserAccessEditPage'
import { ParentDomainsPage } from '@/pages/admin/parent-domains/ParentDomainsPage'
import { SettingsPage } from '@/pages/sovereign/SettingsPage'
import { NotificationsPage } from '@/pages/sovereign/NotificationsPage'
import { ConsoleDashboardPage } from '@/pages/sovereign/console/ConsoleDashboardPage'
@ -707,6 +708,24 @@ const consoleSMERolesRoute = createRoute({
component: SMERolesPage,
})
/* Multi-domain Sovereign Parent Domains admin (issue #829)
*
* Operator-admin "Add another parent domain" surface + DNS propagation
* status panel. Mounted under /console/* so it sits behind the same
* RequireSession + Sovereign-tier auth gate every other admin page uses.
*
* /console/parent-domains ParentDomainsPage
*
* Visibility in the sidebar is decided in SovereignSidebar.tsx by
* checking the operator-admin role; the route registration here is
* always present so a deep-link from the welcome email still resolves.
*/
const consoleParentDomainsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/parent-domains',
component: ParentDomainsPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
@ -754,6 +773,7 @@ const routeTree = rootRoute.addChildren([
consoleSettingsMarketplaceRoute,
consoleSMEUsersRoute,
consoleSMERolesRoute,
consoleParentDomainsRoute,
]),
])

View File

@ -0,0 +1,84 @@
/**
* ParentDomainsPage.test.tsx admin parent-domains surface coverage
* (issue #829, parent epic #825).
*
* Empty state renders when no items
* Populated table renders one row per item with role + status
* Add CTA opens the modal with all four fields
* Drawer toggles open/close on the row caret
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { ParentDomainsPage } from './ParentDomainsPage'
import type { ParentDomain } from './parentDomains.api'
afterEach(cleanup)
const sampleItems: ParentDomain[] = [
{
name: 'omani.works',
role: 'primary',
flipStatus: 'ready',
addedAt: '2026-05-04T08:00:00Z',
flippedAt: '2026-05-04T08:30:00Z',
},
{
name: 'omani.trade',
role: 'sme-pool',
flipStatus: 'flipping',
registrarKind: 'dynadot',
addedAt: '2026-05-04T09:00:00Z',
},
]
describe('ParentDomainsPage', () => {
it('renders the empty state when items list is empty', () => {
render(<ParentDomainsPage initialItems={[]} disableFetch />)
expect(screen.getByTestId('parent-domains-page')).toBeTruthy()
expect(screen.getByTestId('parent-domains-empty')).toBeTruthy()
expect(screen.getByTestId('parent-domains-add-cta')).toBeTruthy()
})
it('renders one row per item with role + status badges', () => {
render(<ParentDomainsPage initialItems={sampleItems} disableFetch />)
expect(screen.getByTestId('parent-domain-row-omani.works')).toBeTruthy()
expect(screen.getByTestId('parent-domain-row-omani.trade')).toBeTruthy()
expect(screen.getByTestId('parent-domain-role-omani.works').textContent).toContain('primary')
expect(screen.getByTestId('parent-domain-role-omani.trade').textContent).toContain('sme-pool')
expect(screen.getByTestId('parent-domain-status-omani.works').textContent).toContain('Ready')
expect(screen.getByTestId('parent-domain-status-omani.trade').textContent).toContain('Flipping')
})
it('locks delete on the primary row', () => {
render(<ParentDomainsPage initialItems={sampleItems} disableFetch />)
expect(screen.queryByTestId('parent-domain-delete-omani.works')).toBeNull()
expect(screen.getByTestId('parent-domain-delete-omani.trade')).toBeTruthy()
})
it('opens the add-domain modal on CTA click', () => {
render(<ParentDomainsPage initialItems={[]} disableFetch />)
fireEvent.click(screen.getByTestId('parent-domains-add-cta'))
expect(screen.getByTestId('add-domain-modal')).toBeTruthy()
expect(screen.getByTestId('add-domain-name')).toBeTruthy()
expect(screen.getByTestId('add-domain-role')).toBeTruthy()
expect(screen.getByTestId('add-domain-registrar')).toBeTruthy()
expect(screen.getByTestId('add-domain-token')).toBeTruthy()
expect(screen.getByTestId('add-domain-submit')).toBeTruthy()
})
it('expands the propagation drawer on row toggle', () => {
render(<ParentDomainsPage initialItems={sampleItems} disableFetch />)
expect(screen.queryByTestId('parent-domain-drawer-omani.trade')).toBeNull()
fireEvent.click(screen.getByTestId('parent-domain-toggle-omani.trade'))
expect(screen.getByTestId('parent-domain-drawer-omani.trade')).toBeTruthy()
})
it('renders the propagation panel with disabled polling for tests', () => {
render(<ParentDomainsPage initialItems={sampleItems} disableFetch />)
fireEvent.click(screen.getByTestId('parent-domain-toggle-omani.trade'))
// disablePolling propagated to PropagationPanel — null result means
// the panel rendered but didn't fire the network call.
expect(screen.getByTestId('parent-domain-drawer-omani.trade')).toBeTruthy()
})
})

View File

@ -0,0 +1,433 @@
/**
* ParentDomainsPage admin "Parent Domains" surface (issue #829, parent
* epic #825).
*
* Mounted at /console/parent-domains in the SovereignConsoleLayout.
* Visible only to operator-admin role (gating handled at route level
* via the same RequireSession guard the rest of /console/* uses).
*
* Layout:
* Header with title + "+ Add another domain" CTA
* Table: Name | Role | Status | Registrar | Added | (Delete)
* Each row expands inline into a PropagationPanel
* Add modal: name, role, registrarKind, registrarToken
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall, target-state shape): the page renders the empty
* state on first paint while the fetch runs in the background; the
* table replaces the empty state once the response lands.
* #4 (never hardcode): every URL flows through the
* parentDomains.api.ts wrappers; no inline `/api/...` strings.
* #10 (credential hygiene): the registrarToken field uses input
* type="password" so the operator's screen recording / shoulder
* surfing risk is minimised. The token is sent once on POST and
* never returned in the GET list response.
*/
import { useEffect, useState } from 'react'
import {
addParentDomain,
deleteParentDomain,
flipStatusLabel,
flipStatusTone,
listParentDomains,
type AddParentDomainRequest,
type ParentDomain,
type ParentDomainRole,
} from './parentDomains.api'
import { PropagationPanel } from './PropagationPanel'
export interface ParentDomainsPageProps {
/** Test seam — supplies the initial list synchronously. */
initialItems?: ParentDomain[]
/** Test seam — disables fetch + propagation polling. */
disableFetch?: boolean
}
export function ParentDomainsPage({
initialItems,
disableFetch = false,
}: ParentDomainsPageProps = {}) {
const [items, setItems] = useState<ParentDomain[]>(initialItems ?? [])
const [loading, setLoading] = useState<boolean>(!initialItems && !disableFetch)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [expanded, setExpanded] = useState<string | null>(null)
async function refresh() {
try {
setLoading(true)
const rows = await listParentDomains()
setItems(rows)
setError(null)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (disableFetch) return
void refresh()
// refresh is intentionally re-created on every render; we only want
// to refire on disableFetch changes (i.e. test seam toggle).
}, [disableFetch])
async function onDelete(item: ParentDomain) {
if (item.role === 'primary') {
alert('Cannot remove the Sovereign primary domain.')
return
}
if (
!confirm(
`Remove parent domain "${item.name}"? SMEs already using subdomains under this zone will continue to work; only NEW SME signups stop being offered this domain.`,
)
) {
return
}
try {
await deleteParentDomain(item.name)
setItems((rows) => rows.filter((r) => r.name !== item.name))
} catch (err) {
setError((err as Error).message)
}
}
return (
<div data-testid="parent-domains-page" className="px-6 py-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-[var(--color-text-strong)]">Parent Domains</h1>
<p className="text-sm text-[var(--color-text-dim)]">
Domains served by this Sovereign's PowerDNS. The primary hosts your console + API; sme-pool domains are offered to SME tenants for free subdomain allocation.
</p>
</div>
<button
type="button"
data-testid="parent-domains-add-cta"
onClick={() => setShowModal(true)}
className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90"
>
+ Add another domain
</button>
</div>
{error ? (
<div
data-testid="parent-domains-error"
className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300"
>
{error}
</div>
) : null}
{loading ? (
<div data-testid="parent-domains-loading" className="text-sm text-[var(--color-text-dim)]">
Loading
</div>
) : items.length === 0 ? (
<div
data-testid="parent-domains-empty"
className="rounded-md border border-[var(--color-border)] px-4 py-8 text-center text-sm text-[var(--color-text-dim)]"
>
No parent domains in the pool yet. Click "+ Add another domain" to flip a domain's NS records to this Sovereign.
</div>
) : (
<table
data-testid="parent-domains-table"
className="w-full border-collapse text-sm"
>
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-xs uppercase text-[var(--color-text-dim)]">
<th className="py-2 pr-3">Domain</th>
<th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Registrar</th>
<th className="py-2 pr-3">Added</th>
<th className="py-2 pr-3 text-right" aria-label="actions" />
</tr>
</thead>
<tbody>
{items.map((item) => {
const isExpanded = expanded === item.name
return (
<ParentDomainRow
key={item.name}
item={item}
expanded={isExpanded}
disablePolling={disableFetch}
onToggle={() => setExpanded(isExpanded ? null : item.name)}
onDelete={() => onDelete(item)}
/>
)
})}
</tbody>
</table>
)}
{showModal && (
<AddDomainModal
onClose={() => setShowModal(false)}
onCreated={async (created) => {
setShowModal(false)
setItems((rows) => [...rows.filter((r) => r.name !== created.name), created])
setExpanded(created.name)
// Background refresh so any stub rows produced server-side
// are reconciled.
void refresh()
}}
/>
)}
</div>
)
}
interface RowProps {
item: ParentDomain
expanded: boolean
disablePolling: boolean
onToggle: () => void
onDelete: () => void
}
function ParentDomainRow({ item, expanded, disablePolling, onToggle, onDelete }: RowProps) {
return (
<>
<tr
data-testid={`parent-domain-row-${item.name}`}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-2)]"
>
<td className="py-2 pr-3">
<button
type="button"
data-testid={`parent-domain-toggle-${item.name}`}
onClick={onToggle}
className="flex items-center gap-1 font-mono text-[var(--color-text)] hover:underline"
>
<span aria-hidden>{expanded ? '▾' : '▸'}</span>
{item.name}
</button>
</td>
<td className="py-2 pr-3">
<span
data-testid={`parent-domain-role-${item.name}`}
className={`rounded-full px-2 py-0.5 text-xs ${
item.role === 'primary'
? 'bg-blue-500/15 text-blue-300'
: 'bg-purple-500/15 text-purple-300'
}`}
>
{item.role}
</span>
</td>
<td className="py-2 pr-3">
<FlipStatusBadge item={item} />
</td>
<td className="py-2 pr-3 text-xs text-[var(--color-text-dim)]">
{item.registrarKind ?? '—'}
</td>
<td className="py-2 pr-3 text-xs text-[var(--color-text-dim)]">
{item.addedAt && item.addedAt.startsWith('0001')
? '—'
: new Date(item.addedAt).toLocaleDateString()}
</td>
<td className="py-2 pr-3 text-right">
{item.role === 'primary' ? (
<span className="text-xs text-[var(--color-text-dim)]">locked</span>
) : (
<button
type="button"
data-testid={`parent-domain-delete-${item.name}`}
onClick={onDelete}
className="text-xs text-red-400 hover:underline"
>
Remove
</button>
)}
</td>
</tr>
{expanded && (
<tr data-testid={`parent-domain-drawer-${item.name}`}>
<td
colSpan={6}
className="bg-[var(--color-bg-2)] border-b border-[var(--color-border)]"
>
<PropagationPanel domainName={item.name} disablePolling={disablePolling} />
</td>
</tr>
)}
</>
)
}
function FlipStatusBadge({ item }: { item: ParentDomain }) {
const tone = flipStatusTone(item.flipStatus)
const label = flipStatusLabel(item.flipStatus)
return (
<span
data-testid={`parent-domain-status-${item.name}`}
title={item.flipMessage}
className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold ${
tone === 'green'
? 'bg-emerald-500/15 text-emerald-300'
: tone === 'amber'
? 'bg-amber-500/15 text-amber-300'
: tone === 'red'
? 'bg-red-500/15 text-red-300'
: 'bg-blue-500/15 text-blue-300'
}`}
>
{label}
</span>
)
}
interface AddDomainModalProps {
onClose: () => void
onCreated: (item: ParentDomain) => void
}
function AddDomainModal({ onClose, onCreated }: AddDomainModalProps) {
const [name, setName] = useState('')
const [role, setRole] = useState<ParentDomainRole>('sme-pool')
const [registrarKind, setRegistrarKind] = useState('dynadot')
const [token, setToken] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || !token.trim()) {
setError('Name and registrar token are required.')
return
}
setSubmitting(true)
setError(null)
try {
const req: AddParentDomainRequest = {
name: name.trim(),
role,
registrarKind,
registrarToken: token,
}
const created = await addParentDomain(req)
onCreated(created)
} catch (err) {
setError((err as Error).message)
} finally {
setSubmitting(false)
}
}
return (
<div
data-testid="add-domain-modal"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={onClose}
role="presentation"
>
<form
onSubmit={onSubmit}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-6 shadow-xl"
>
<h2 className="mb-1 text-lg font-semibold text-[var(--color-text-strong)]">
Add another parent domain
</h2>
<p className="mb-4 text-xs text-[var(--color-text-dim)]">
OpenOva flips this domain's NS records via the registrar API, bootstraps the PowerDNS zone
on this Sovereign, and issues a wildcard cert via cert-manager. Propagation across public
resolvers takes up to 48h (gTLD NS TTL).
</p>
{error && (
<div
data-testid="add-domain-error"
className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)}
<label className="mb-3 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">Domain</div>
<input
data-testid="add-domain-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="omani.trade"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus
/>
</label>
<label className="mb-3 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">Role</div>
<select
data-testid="add-domain-role"
value={role}
onChange={(e) => setRole(e.target.value as ParentDomainRole)}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
<option value="sme-pool">sme-pool offered to SME tenants</option>
<option value="primary">primary operator's own domain</option>
</select>
</label>
<label className="mb-3 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">Registrar</div>
<select
data-testid="add-domain-registrar"
value={registrarKind}
onChange={(e) => setRegistrarKind(e.target.value)}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
<option value="dynadot">Dynadot</option>
<option value="cloudflare">Cloudflare</option>
<option value="namecheap">Namecheap</option>
<option value="godaddy">GoDaddy</option>
<option value="ovh">OVH</option>
</select>
</label>
<label className="mb-4 block">
<div className="mb-1 text-xs font-medium text-[var(--color-text-dim)]">
Registrar API token
</div>
<input
data-testid="add-domain-token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="••••••••••••••••"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-sm font-mono text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoComplete="off"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-dim)]">
Used once to flip the NS records. Never logged or persisted in plaintext.
</div>
</label>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm text-[var(--color-text-dim)] hover:bg-[var(--color-bg-2)]"
>
Cancel
</button>
<button
type="submit"
data-testid="add-domain-submit"
disabled={submitting}
className="rounded-md bg-[var(--color-accent)] px-4 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? 'Adding…' : 'Add domain'}
</button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,223 @@
/**
* PropagationPanel live DNS propagation status for a parent domain
* (issue #829, parent epic #825).
*
* Renders one row per public DNS resolver the catalyst-api queries. Each
* row has:
* - resolver name + geo (e.g. "Google · US")
* - status pill (green=converged / amber=diverged / red=error)
* - returned NS records (truncated at 2 lines)
* - latency
*
* Polls GET /api/v1/sovereign/parent-domains/<name>/propagation every
* 60s. Per-page poll, not per-row, so we do NOT hammer public resolvers
* (the catalyst-api fans out 5 lookups per request).
*
* Consumed by ParentDomainsPage.tsx as a collapsible per-row drawer.
*/
import { useEffect, useState } from 'react'
import { getPropagation, type PropagationResponse, type ResolverStatus } from './parentDomains.api'
const POLL_INTERVAL_MS = 60_000
export interface PropagationPanelProps {
domainName: string
/** Test seam — supplies the initial payload synchronously without firing fetch. */
initialData?: PropagationResponse
/** Test seam — disables both the fetch effect and the interval. */
disablePolling?: boolean
}
export function PropagationPanel({
domainName,
initialData,
disablePolling = false,
}: PropagationPanelProps) {
const [data, setData] = useState<PropagationResponse | null>(initialData ?? null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(!initialData && !disablePolling)
const [lastFetchedAt, setLastFetchedAt] = useState<Date | null>(null)
useEffect(() => {
if (disablePolling) return
let cancelled = false
async function poll() {
try {
const resp = await getPropagation(domainName)
if (cancelled) return
setData(resp)
setError(null)
setLastFetchedAt(new Date())
} catch (err) {
if (!cancelled) setError((err as Error).message)
} finally {
if (!cancelled) setLoading(false)
}
}
poll()
const id = setInterval(poll, POLL_INTERVAL_MS)
return () => {
cancelled = true
clearInterval(id)
}
}, [domainName, disablePolling])
if (loading) {
return (
<div
data-testid={`propagation-loading-${domainName}`}
className="px-4 py-3 text-xs text-[var(--color-text-dim)]"
>
Querying public DNS resolvers
</div>
)
}
if (error && !data) {
return (
<div
data-testid={`propagation-error-${domainName}`}
className="m-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)
}
if (!data) {
return null
}
const pctTone =
data.percentage >= 80 ? 'green' : data.percentage >= 30 ? 'amber' : 'red'
return (
<div data-testid={`propagation-panel-${domainName}`} className="px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-xs uppercase tracking-wide text-[var(--color-text-dim)]">
DNS Propagation
</div>
<div className="mt-1 text-sm text-[var(--color-text)]">
<span className="font-mono">{domainName}</span> querying{' '}
<span className="font-medium">{data.total}</span> public resolvers
</div>
</div>
<div
data-testid={`propagation-pct-${domainName}`}
className={`rounded-full px-3 py-1 text-sm font-semibold ${
pctTone === 'green'
? 'bg-emerald-500/15 text-emerald-300'
: pctTone === 'amber'
? 'bg-amber-500/15 text-amber-300'
: 'bg-red-500/15 text-red-300'
}`}
>
{data.percentage}% propagated
</div>
</div>
{data.expectedNs.length > 0 && (
<div className="mb-2 text-xs text-[var(--color-text-dim)]">
Expected NS:{' '}
{data.expectedNs.map((n) => (
<code
key={n}
className="ml-1 rounded bg-[var(--color-bg-2)] px-1.5 py-0.5 text-[11px]"
>
{n}
</code>
))}
</div>
)}
<table
data-testid={`propagation-table-${domainName}`}
className="w-full border-collapse text-xs"
>
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-[10px] uppercase text-[var(--color-text-dim)]">
<th className="py-1.5 pr-2">Resolver</th>
<th className="py-1.5 pr-2">IP</th>
<th className="py-1.5 pr-2">Status</th>
<th className="py-1.5 pr-2">Returned NS</th>
<th className="py-1.5 pr-2 text-right">Latency</th>
</tr>
</thead>
<tbody>
{data.resolvers.map((r) => (
<tr
key={r.resolver.ip}
data-testid={`propagation-row-${r.resolver.ip}`}
data-status={r.status}
className="border-b border-[var(--color-border)]"
>
<td className="py-1.5 pr-2 text-[var(--color-text)]">
{r.resolver.name}{' '}
<span className="text-[10px] text-[var(--color-text-dim)]">· {r.resolver.geo}</span>
</td>
<td className="py-1.5 pr-2 font-mono text-[11px] text-[var(--color-text-dim)]">
{r.resolver.ip}
</td>
<td className="py-1.5 pr-2">
<StatusPill status={r.status} />
</td>
<td className="py-1.5 pr-2 font-mono text-[10px] text-[var(--color-text-dim)]">
{r.error ? (
<span className="text-red-400">{r.error}</span>
) : r.ns.length === 0 ? (
<span className="italic">no NS records</span>
) : (
r.ns.slice(0, 2).join(', ') + (r.ns.length > 2 ? ` +${r.ns.length - 2}` : '')
)}
</td>
<td className="py-1.5 pr-2 text-right text-[var(--color-text-dim)]">
{r.latencyMs}ms
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-2 flex items-center justify-between text-[10px] text-[var(--color-text-dim)]">
<span>
Auto-refreshes every {POLL_INTERVAL_MS / 1000}s. gTLD NS TTL is 48h full convergence
takes time.
</span>
{lastFetchedAt && (
<span data-testid={`propagation-last-${domainName}`}>
Updated {lastFetchedAt.toLocaleTimeString()}
</span>
)}
</div>
</div>
)
}
function StatusPill({ status }: { status: ResolverStatus }) {
const tone =
status === 'converged' ? 'green' : status === 'diverged' ? 'amber' : 'red'
const label =
status === 'converged'
? 'Converged'
: status === 'diverged'
? 'Pending'
: 'Error'
return (
<span
data-testid={`status-pill-${status}`}
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold ${
tone === 'green'
? 'bg-emerald-500/15 text-emerald-300'
: tone === 'amber'
? 'bg-amber-500/15 text-amber-300'
: 'bg-red-500/15 text-red-300'
}`}
>
{label}
</span>
)
}

View File

@ -0,0 +1,181 @@
/**
* parentDomains.api.ts typed REST client for the admin "parent
* domains" surface (issue #829, parent epic #825).
*
* Wire shape mirrors the catalyst-api handler in
* products/catalyst/bootstrap/api/internal/handler/parent_domains.go.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every URL
* derives from the central API_BASE constant there are no inline
* `/api/...` strings here.
*/
import { API_BASE } from '@/shared/config/urls'
export type ParentDomainRole = 'primary' | 'sme-pool'
export type FlipStatus =
| 'queued'
| 'flipping'
| 'flipped'
| 'failed'
| 'zone-creating'
| 'cert-issuing'
| 'ready'
export interface ParentDomain {
name: string
role: ParentDomainRole
registrarKind?: string
registrarCredsRef?: string
flipStatus: FlipStatus
flipMessage?: string
addedAt: string
flippedAt?: string
}
export interface ParentDomainListResponse {
items: ParentDomain[]
}
export interface AddParentDomainRequest {
name: string
role: ParentDomainRole
registrarKind: string
registrarToken: string
}
export interface ResolverSpec {
name: string
ip: string
geo: string
}
export type ResolverStatus = 'converged' | 'diverged' | 'error'
export interface PropagationState {
resolver: ResolverSpec
status: ResolverStatus
ns: string[]
queriedAt: string
latencyMs: number
error?: string
}
export interface PropagationResponse {
domain: string
expectedNs: string[]
resolvers: PropagationState[]
converged: number
total: number
percentage: number
generatedAt: string
}
const PARENT_DOMAINS_BASE = `${API_BASE}/v1/sovereign/parent-domains`
export async function listParentDomains(): Promise<ParentDomain[]> {
const res = await fetch(PARENT_DOMAINS_BASE, {
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(`list parent-domains: HTTP ${res.status}`)
}
const body = (await res.json()) as ParentDomainListResponse
return body.items ?? []
}
export async function addParentDomain(req: AddParentDomainRequest): Promise<ParentDomain> {
const res = await fetch(PARENT_DOMAINS_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(req),
})
// 201 (created) or 502 (registrar/zone failure) — both bodies carry
// the partial state. Surface the body's `detail` to the modal so the
// operator sees the registrar's actual error message.
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = (await res.json()) as { detail?: string; error?: string }
detail = body.detail ?? body.error ?? detail
} catch {
// ignore non-JSON error body
}
throw new Error(`add parent-domain: ${detail}`)
}
return (await res.json()) as ParentDomain
}
export async function deleteParentDomain(name: string): Promise<void> {
const res = await fetch(`${PARENT_DOMAINS_BASE}/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
})
if (!res.ok && res.status !== 204) {
let detail = `HTTP ${res.status}`
try {
const body = (await res.json()) as { detail?: string; error?: string }
detail = body.detail ?? body.error ?? detail
} catch {
// ignore
}
throw new Error(`delete parent-domain: ${detail}`)
}
}
export async function getPropagation(name: string): Promise<PropagationResponse> {
const res = await fetch(
`${PARENT_DOMAINS_BASE}/${encodeURIComponent(name)}/propagation`,
{ headers: { Accept: 'application/json' } },
)
if (!res.ok) {
throw new Error(`get propagation: HTTP ${res.status}`)
}
return (await res.json()) as PropagationResponse
}
/**
* Pretty label for a FlipStatus enum value used by the list-row
* badge.
*/
export function flipStatusLabel(s: FlipStatus): string {
switch (s) {
case 'queued':
return 'Queued'
case 'flipping':
return 'Flipping NS'
case 'flipped':
return 'NS Flipped'
case 'zone-creating':
return 'Creating Zone'
case 'cert-issuing':
return 'Issuing Cert'
case 'ready':
return 'Ready'
case 'failed':
return 'Failed'
default:
return s
}
}
/**
* Badge tone (matches global theme colour tokens) for a FlipStatus.
*/
export function flipStatusTone(s: FlipStatus): 'green' | 'amber' | 'red' | 'blue' {
switch (s) {
case 'ready':
case 'flipped':
return 'green'
case 'queued':
case 'flipping':
case 'zone-creating':
case 'cert-issuing':
return 'amber'
case 'failed':
return 'red'
default:
return 'blue'
}
}

View File

@ -102,6 +102,12 @@ interface SubNavItem {
const SETTINGS_SUB_NAV: SubNavItem[] = [
{ id: 'marketplace', label: 'Marketplace', to: '/console/settings/marketplace' },
// Parent Domains — admin "Add another parent domain" + DNS propagation
// status panel (issue #829). Lives under Settings so the sidebar
// surface stays compact for the typical SME tenant who never sees
// this surface; operator-admins reach it via /console/parent-domains
// directly from the welcome email or by clicking through Settings.
{ id: 'parent-domains', label: 'Parent Domains', to: '/console/parent-domains' },
]
// ── Active-state derivation ───────────────────────────────────────────────────
@ -116,7 +122,12 @@ function deriveActiveSection(pathname: string): ActiveSection {
if (/\/console\/jobs(\/|$)/.test(pathname)) return 'jobs'
if (/\/console\/users(\/|$)/.test(pathname)) return 'users'
if (/\/console\/catalog(\/|$)/.test(pathname)) return 'catalog'
// /console/settings/* OR /console/parent-domains → 'settings' so the
// Settings nav item highlights and the sub-nav (Marketplace + Parent
// Domains) expands. Per inviolable principle #4, the path list is
// pulled from SETTINGS_SUB_NAV rather than re-typed here.
if (/\/console\/settings(\/|$)/.test(pathname)) return 'settings'
if (SETTINGS_SUB_NAV.some((s) => pathname.startsWith(s.to))) return 'settings'
return 'apps'
}