Registry.ClaimExternal now enforces the plan gate itself via an injected domains.ExternalClaimGate (pre-lock, typed refusals), so every entry point — and any future consumer — inherits it from the allocation API. One constructor in internal/server builds the gate from the entitlements querier and connect target; it is injected into the member-facing registry constructions in server.go and fedwiki.go and drives affordance rendering on both surfaces. The duplicated helpers and resource-key constants in fedwiki web and member_domains are gone; fedwiki no longer reads entitlement tables for this gate at all. Archives the change with the domains-registry spec delta (enforcement location is now requirement-level: registry-inherited, surfaces derive). Closes the entitlement-gate placement debt in issues.md; files the separately-discovered operator force-release dead-end affordance bug that a placed claim exposed in the domains walkthrough.
751 lines
29 KiB
Go
751 lines
29 KiB
Go
// Member domain claims at point of use (dissolve-member-domains, née the
|
|
// /domains page per domains-registry design D9): the HTMX partials hosted by
|
|
// the sites surface's Domains section and the dashboard's pending-claim
|
|
// notice — the workspace's live claims and one claim's DNS instructions with
|
|
// its per-record probe state. Claim initiation lives in the site-creation
|
|
// flow; the AddClaim POST remains routed and fully gated for compatibility,
|
|
// but no surface renders its form anymore.
|
|
//
|
|
// Every claim operation is owner-scoped through the registry, which reports a
|
|
// claim owned by another workspace and a claim that does not exist
|
|
// identically (domains.ErrNotFound), so an id probe never becomes an
|
|
// existence oracle. Claim initiation mirrors FedWiki's create glue
|
|
// (internal/integrations/fedwiki/web/customdomain.go) step for step — one
|
|
// claim model, one verification workflow, two entry points — with the single
|
|
// distinction this surface owes its member: their OWN pending claim at the
|
|
// name is named and linked, instead of collapsing to the generic refusal that
|
|
// any other workspace's claim gets.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
wfdomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
|
|
"go.temporal.io/sdk/client"
|
|
)
|
|
|
|
// Member-facing copy for the two ways the add-a-domain affordance can be
|
|
// closed. They are deliberately different: only one of them is something the
|
|
// member can act on.
|
|
const (
|
|
planGateMessage = "Adding a domain you own isn't part of your current plan. " +
|
|
"Upgrade from the Products page to enable it."
|
|
noConnectTargetMessage = "Domains you own can't be connected on this deployment yet. " +
|
|
"Ask your operator to configure a connect target."
|
|
)
|
|
|
|
// MemberDomainsHandler serves the /domains page's partials.
|
|
type MemberDomainsHandler struct {
|
|
// DomainsQ is the registry store, used for the reads that are plain
|
|
// lookups rather than allocation decisions (naming the placements that
|
|
// block a release).
|
|
DomainsQ domains.Querier
|
|
// Registry is the allocation API: every claim read and write on this
|
|
// surface goes through it, so the ownership and disjointness rules are
|
|
// enforced in exactly one place.
|
|
Registry *domains.Registry
|
|
TemporalClient client.Client // nil if Temporal isn't configured
|
|
AuthConfig *auth.Config
|
|
Logger *slog.Logger
|
|
// ConnectTarget is the DNS target members point external domains at.
|
|
// Empty disables external claims deployment-wide (design D4/D7). Still
|
|
// held here for rendering the connect record's value; the gate below is
|
|
// what decides admission.
|
|
ConnectTarget string
|
|
Templates *SafeTemplates
|
|
// gate is the shared external-claim gate
|
|
// (centralize-external-claim-gate), built in the constructor from the
|
|
// same querier + connect target the registry's enforcement uses.
|
|
gate domains.ExternalClaimGate
|
|
}
|
|
|
|
// MemberDomainsConfig holds configuration for the member domains handler.
|
|
type MemberDomainsConfig struct {
|
|
DomainsQ domains.Querier
|
|
Registry *domains.Registry
|
|
EntitlementsQ entitlements.Querier
|
|
TemporalClient client.Client
|
|
AuthConfig *auth.Config
|
|
Logger *slog.Logger
|
|
ConnectTarget string
|
|
}
|
|
|
|
// NewMemberDomainsHandler creates a new MemberDomainsHandler.
|
|
func NewMemberDomainsHandler(cfg MemberDomainsConfig) (*MemberDomainsHandler, error) {
|
|
templateSubFS, err := fs.Sub(embeds.Templates, "templates/partials")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tmpl, err := template.New("member").Funcs(template.FuncMap{
|
|
"routeURL": web.RouteURL,
|
|
}).ParseFS(templateSubFS, "member_*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &MemberDomainsHandler{
|
|
DomainsQ: cfg.DomainsQ,
|
|
Registry: cfg.Registry,
|
|
TemporalClient: cfg.TemporalClient,
|
|
AuthConfig: cfg.AuthConfig,
|
|
Logger: cfg.Logger,
|
|
ConnectTarget: strings.TrimSpace(cfg.ConnectTarget),
|
|
Templates: NewSafeTemplates(tmpl, cfg.Logger),
|
|
gate: NewExternalClaimGate(cfg.EntitlementsQ, cfg.ConnectTarget),
|
|
}, nil
|
|
}
|
|
|
|
// RegisterRoutes registers the member domains partial routes. The claim
|
|
// status route is a GET and stays one: reopening DNS instructions is a pure
|
|
// read that mints nothing (spec: "reopening them SHALL NOT re-initiate a
|
|
// claim or mint new records").
|
|
func (h *MemberDomainsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /partials/domains/claims", h.GetClaims)
|
|
mux.HandleFunc("POST /partials/domains/claims", h.AddClaim)
|
|
mux.HandleFunc("GET /partials/domains/claims/{claimID}", h.GetClaimStatus)
|
|
mux.HandleFunc("POST /partials/domains/claims/{claimID}/check", h.CheckClaimNow)
|
|
mux.HandleFunc("POST /partials/domains/claims/{claimID}/cancel", h.CancelClaim)
|
|
mux.HandleFunc("POST /partials/domains/claims/{claimID}/release", h.ReleaseClaim)
|
|
}
|
|
|
|
// --- View models ---
|
|
|
|
// MemberDomainClaimRow is one row of the claims table.
|
|
type MemberDomainClaimRow struct {
|
|
ClaimID string
|
|
Root string
|
|
// KindLabel is member-facing wording, not the stored kind: operator
|
|
// roots and carved member claims are both "Hosted" (names the deployment
|
|
// runs), external claims are "Custom" (a domain the member owns).
|
|
KindLabel string
|
|
KindClass string
|
|
Status string
|
|
StatusClass string
|
|
Placements int64
|
|
// External marks a claim whose DNS instructions are worth opening.
|
|
External bool
|
|
// Pending distinguishes a claim still waiting on DNS from a settled one,
|
|
// which is what the instructions link is called: records to publish
|
|
// versus records to review. Cancel itself lives in the instructions view,
|
|
// where the pending-only guard is applied (design D4).
|
|
Pending bool
|
|
// CanRelease is true for an active member/external claim holding no
|
|
// placement — the release guard, mirrored server-side.
|
|
CanRelease bool
|
|
}
|
|
|
|
// MemberDomainsData feeds member_domains.html.
|
|
type MemberDomainsData struct {
|
|
Claims []MemberDomainClaimRow
|
|
// CanAddExternal renders the add-a-domain affordance. Both halves of the
|
|
// gate must hold (entitlement granted AND connect target configured);
|
|
// every POST re-enforces it, so this is presentation only.
|
|
CanAddExternal bool
|
|
Error string
|
|
Notice string
|
|
// FormDomain preserves what the member typed when the form comes back
|
|
// with a refusal.
|
|
FormDomain string
|
|
FormError string
|
|
// FormClaimID is set only when the refusal was the member's OWN pending
|
|
// claim at that name, so the form can link to its instructions instead of
|
|
// leaving them at a dead end. Any other holder collapses to FormError.
|
|
FormClaimID string
|
|
}
|
|
|
|
// MemberDomainRecordView is one row of the DNS instructions table: a record
|
|
// plus its latest probe result.
|
|
type MemberDomainRecordView struct {
|
|
Kind string // record type shown to the member: TXT / CNAME
|
|
Purpose string // what this record is for, one line
|
|
Name string // DNS name to create the record at
|
|
Value string // required record value
|
|
State string // domains.ProbeState*: unchecked|missing|mismatch|match|error
|
|
Observed []string // what the last probe actually saw (mismatch diagnostics)
|
|
// Optional marks a record that is guidance, not a requirement: the
|
|
// wildcard row (design D9). Nothing probes it and verification never
|
|
// waits on it, so its status column reads "optional".
|
|
Optional bool
|
|
}
|
|
|
|
// MemberDomainClaimData feeds member_domains_claim.html.
|
|
type MemberDomainClaimData struct {
|
|
ClaimID string
|
|
Domain string
|
|
Records []MemberDomainRecordView
|
|
StatusText string
|
|
LastChecked string // "" until the first probe lands
|
|
ExpiresAt string
|
|
// Polling drives the 5s in-place refresh; true only while pending.
|
|
Polling bool
|
|
// Verified marks an active claim. Verification creates nothing (design
|
|
// D4) — site creation lives on the provider's own card — so the verified
|
|
// branch says where to go rather than offering a create here.
|
|
Verified bool
|
|
Placed bool
|
|
Expired bool
|
|
Canceled bool
|
|
Released bool
|
|
// Hosted marks a claim that is not an external one: it carries no
|
|
// challenge and has no records to publish.
|
|
Hosted bool
|
|
Error string
|
|
}
|
|
|
|
// --- Presentation helpers ---
|
|
|
|
// domainKindLabel maps a stored claim kind to member-facing wording plus its
|
|
// badge class. Members never see the registry's vocabulary: what matters to
|
|
// them is whether a name is one the deployment hosts or one they own.
|
|
func domainKindLabel(kind string) (string, string) {
|
|
switch kind {
|
|
case domains.KindExternal:
|
|
return "Custom", "text-bg-primary"
|
|
default: // operator_root, member
|
|
return "Hosted", "text-bg-secondary"
|
|
}
|
|
}
|
|
|
|
// domainStatusLabel maps a claim status to member-facing wording plus its
|
|
// badge class.
|
|
func domainStatusLabel(status string) (string, string) {
|
|
switch status {
|
|
case domains.StatusPending:
|
|
return "Awaiting DNS", "text-bg-warning"
|
|
case domains.StatusActive:
|
|
return "Active", "text-bg-success"
|
|
case domains.StatusExpired:
|
|
return "Expired", "text-bg-secondary"
|
|
case domains.StatusCanceled:
|
|
return "Canceled", "text-bg-secondary"
|
|
case domains.StatusReleased:
|
|
return "Released", "text-bg-secondary"
|
|
default:
|
|
return status, "text-bg-secondary"
|
|
}
|
|
}
|
|
|
|
// newMemberDomainRecords builds the records table in display order: the TXT
|
|
// challenge (the only record verification gates on), the connect record, and
|
|
// the OPTIONAL wildcard row (design D9) that lets any subdomain of the claim
|
|
// resolve without a new DNS record — which is exactly the shape a claim has,
|
|
// since every name inside it places instantly.
|
|
func newMemberDomainRecords(domain, txtName, txtValue, connectTarget string) []MemberDomainRecordView {
|
|
return []MemberDomainRecordView{
|
|
{
|
|
Kind: "TXT",
|
|
Purpose: "Proves you control the domain",
|
|
Name: txtName,
|
|
Value: txtValue,
|
|
State: domains.ProbeStateUnchecked,
|
|
},
|
|
{
|
|
Kind: "CNAME",
|
|
Purpose: "Points the domain at your site",
|
|
Name: domain,
|
|
Value: connectTarget,
|
|
State: domains.ProbeStateUnchecked,
|
|
},
|
|
{
|
|
Kind: "CNAME",
|
|
Purpose: "Optional — lets any subdomain work without new DNS records",
|
|
Name: "*." + domain,
|
|
Value: connectTarget,
|
|
State: domains.ProbeStateUnchecked,
|
|
Optional: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// splitProbeObserved undoes the probe activity's newline join.
|
|
func splitProbeObserved(joined string) []string {
|
|
if joined == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(joined, "\n")
|
|
}
|
|
|
|
// --- Reads ---
|
|
|
|
// externalClaimsEnabled is the affordance read over the shared gate
|
|
// (centralize-external-claim-gate): a typed refusal is simply "closed", and
|
|
// only an infrastructure failure surfaces as an error. Enforcement no longer
|
|
// lives here — the registry's ClaimExternal carries the same gate.
|
|
func (h *MemberDomainsHandler) externalClaimsEnabled(ctx context.Context, workspaceID string) (bool, error) {
|
|
err := h.gate(ctx, workspaceID)
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case externalClaimGateRefused(err):
|
|
return false, nil
|
|
default:
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// surfaceData assembles the whole Domains surface: the gate plus the
|
|
// workspace's live claims with their placement counts.
|
|
func (h *MemberDomainsHandler) surfaceData(ctx context.Context, workspaceID string) MemberDomainsData {
|
|
data := MemberDomainsData{}
|
|
|
|
enabled, err := h.externalClaimsEnabled(ctx, workspaceID)
|
|
if err != nil {
|
|
// A failed gate read closes the affordance rather than opening it:
|
|
// the POST would refuse anyway, and a dead button is worse than none.
|
|
h.Logger.Error("failed to read external-domain entitlement",
|
|
slog.String("workspace_id", workspaceID), slog.Any("error", err))
|
|
}
|
|
data.CanAddExternal = enabled
|
|
|
|
if h.Registry == nil {
|
|
data.Error = "Domains aren't available right now."
|
|
return data
|
|
}
|
|
rows, err := h.Registry.ListLiveClaims(ctx, workspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to list domain claims",
|
|
slog.String("workspace_id", workspaceID), slog.Any("error", err))
|
|
data.Error = "Failed to load your domains."
|
|
return data
|
|
}
|
|
for _, row := range rows {
|
|
kindLabel, kindClass := domainKindLabel(row.Kind)
|
|
statusLabel, statusClass := domainStatusLabel(row.Status)
|
|
external := row.Kind == domains.KindExternal
|
|
data.Claims = append(data.Claims, MemberDomainClaimRow{
|
|
ClaimID: row.ClaimID,
|
|
Root: row.RootFqdn,
|
|
KindLabel: kindLabel,
|
|
KindClass: kindClass,
|
|
Status: statusLabel,
|
|
StatusClass: statusClass,
|
|
Placements: row.PlacementCount,
|
|
External: external,
|
|
Pending: row.Status == domains.StatusPending,
|
|
CanRelease: row.Status == domains.StatusActive &&
|
|
row.Kind != domains.KindOperatorRoot &&
|
|
row.PlacementCount == 0,
|
|
})
|
|
}
|
|
return data
|
|
}
|
|
|
|
// claimStatusData renders one claim into the instructions/status view model.
|
|
// The probe columns live on the claim row itself, so the verification
|
|
// workflow's writes and this read touch the same record.
|
|
func (h *MemberDomainsHandler) claimStatusData(ctx context.Context, claim domains.Claim, statusText string) MemberDomainClaimData {
|
|
data := MemberDomainClaimData{
|
|
ClaimID: claim.ClaimID,
|
|
Domain: claim.RootFqdn,
|
|
StatusText: statusText,
|
|
Verified: claim.Status == domains.StatusActive,
|
|
Expired: claim.Status == domains.StatusExpired,
|
|
Canceled: claim.Status == domains.StatusCanceled,
|
|
Released: claim.Status == domains.StatusReleased,
|
|
Polling: claim.Status == domains.StatusPending,
|
|
Hosted: claim.Kind != domains.KindExternal,
|
|
}
|
|
if !data.Hosted {
|
|
records := newMemberDomainRecords(claim.RootFqdn,
|
|
wfdomains.ChallengeRecordName(claim.RootFqdn),
|
|
wfdomains.ChallengeRecordValue(claim.Token),
|
|
h.ConnectTarget)
|
|
records[0].State = claim.TxtState
|
|
records[0].Observed = splitProbeObserved(claim.TxtObserved)
|
|
records[1].State = claim.ConnectState
|
|
records[1].Observed = splitProbeObserved(claim.ConnectObserved)
|
|
data.Records = records
|
|
}
|
|
if claim.ExpiresAt.Valid {
|
|
// With the time of day: the verification window is hours, so a bare
|
|
// date would tell a member they have until the end of a day they do
|
|
// not have.
|
|
data.ExpiresAt = claim.ExpiresAt.Time.Format("Jan 2, 2006 15:04 MST")
|
|
}
|
|
if claim.LastCheckedAt.Valid {
|
|
data.LastChecked = claim.LastCheckedAt.Time.Format("Jan 2, 2006 15:04 MST")
|
|
}
|
|
// A placement at the root means a site already sits at the name, which
|
|
// changes what the verified branch should say.
|
|
if data.Verified && h.Registry != nil {
|
|
if placement, err := h.Registry.PlacementAt(ctx, claim.RootFqdn); err != nil {
|
|
h.Logger.Warn("failed to check placement for verified claim", slog.Any("error", err))
|
|
} else if placement != nil {
|
|
data.Placed = true
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
// session resolves the caller's session, writing the 401 itself when absent.
|
|
func (h *MemberDomainsHandler) session(w http.ResponseWriter, r *http.Request) (*auth.UserSession, bool) {
|
|
session := h.AuthConfig.GetUserSession(r.Context())
|
|
if session == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return nil, false
|
|
}
|
|
return session, true
|
|
}
|
|
|
|
// ownedClaim loads the claim named in the request path and enforces workspace
|
|
// ownership, writing the HTTP error itself when it fails. A claim another
|
|
// workspace owns is indistinguishable from one that does not exist (the
|
|
// registry collapses both to ErrNotFound).
|
|
func (h *MemberDomainsHandler) ownedClaim(w http.ResponseWriter, r *http.Request) (domains.Claim, bool) {
|
|
session, ok := h.session(w, r)
|
|
if !ok {
|
|
return domains.Claim{}, false
|
|
}
|
|
if h.Registry == nil {
|
|
http.NotFound(w, r)
|
|
return domains.Claim{}, false
|
|
}
|
|
claim, err := h.Registry.ClaimByID(r.Context(), session.WorkspaceID, r.PathValue("claimID"))
|
|
if err != nil {
|
|
if !errors.Is(err, domains.ErrNotFound) {
|
|
h.Logger.Error("failed to load domain claim", slog.Any("error", err))
|
|
}
|
|
http.NotFound(w, r)
|
|
return domains.Claim{}, false
|
|
}
|
|
return claim, true
|
|
}
|
|
|
|
// GetClaims handles GET /partials/domains/claims — the surface's list view.
|
|
func (h *MemberDomainsHandler) GetClaims(w http.ResponseWriter, r *http.Request) {
|
|
session, ok := h.session(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
h.Templates.Render(w, "member_domains.html", h.surfaceData(r.Context(), session.WorkspaceID))
|
|
}
|
|
|
|
// GetClaimStatus handles GET /partials/domains/claims/{claimID} — the DNS
|
|
// instructions view and the poll target the pending view swaps itself with.
|
|
// It re-reads the claim and mints nothing.
|
|
func (h *MemberDomainsHandler) GetClaimStatus(w http.ResponseWriter, r *http.Request) {
|
|
claim, ok := h.ownedClaim(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
h.renderClaimStatus(w, r, claim, "")
|
|
}
|
|
|
|
// renderClaimStatus renders the right branch for the claim's current state.
|
|
// statusText overrides the default pending copy when non-empty.
|
|
func (h *MemberDomainsHandler) renderClaimStatus(w http.ResponseWriter, r *http.Request, claim domains.Claim, statusText string) {
|
|
if claim.Status == domains.StatusPending && statusText == "" {
|
|
statusText = "Waiting for your DNS records…"
|
|
}
|
|
h.Templates.Render(w, "member_domains_claim.html", h.claimStatusData(r.Context(), claim, statusText))
|
|
}
|
|
|
|
// AddClaim handles POST /partials/domains/claims — the add-a-domain form.
|
|
// The gate is re-enforced here (the affordance's absence is presentation, not
|
|
// protection), then the name is classified and, when free, claimed and its
|
|
// verification workflow started.
|
|
func (h *MemberDomainsHandler) AddClaim(w http.ResponseWriter, r *http.Request) {
|
|
session, ok := h.session(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
// Normalize before anything reads the name: the registry stores and
|
|
// compares normalized names, so an absolute FQDN ("wiki.example.org.")
|
|
// must lose its root label here or the claim row and the echoed form
|
|
// value disagree.
|
|
root := dnsname.Normalize(r.FormValue("domain"))
|
|
|
|
refuse := func(message string, claimID string) {
|
|
data := h.surfaceData(ctx, session.WorkspaceID)
|
|
data.FormDomain = root
|
|
data.FormError = message
|
|
data.FormClaimID = claimID
|
|
h.Templates.Render(w, "member_domains.html", data)
|
|
}
|
|
|
|
// Pre-flight over the shared gate — presentation only, since the
|
|
// registry's ClaimExternal enforces the same gate regardless
|
|
// (centralize-external-claim-gate). The two refusals get different copy
|
|
// because only one of them is the member's to fix.
|
|
switch err := h.gate(ctx, session.WorkspaceID); {
|
|
case errors.Is(err, domains.ErrNoConnectTarget):
|
|
refuse(noConnectTargetMessage, "")
|
|
return
|
|
case errors.Is(err, domains.ErrExternalClaimsNotEntitled):
|
|
refuse(planGateMessage, "")
|
|
return
|
|
case err != nil:
|
|
h.Logger.Error("failed to read external-domain entitlement",
|
|
slog.String("workspace_id", session.WorkspaceID), slog.Any("error", err))
|
|
refuse("We couldn't check your plan just now. Please try again.", "")
|
|
return
|
|
}
|
|
if root == "" {
|
|
refuse("Enter the domain you want to use.", "")
|
|
return
|
|
}
|
|
if h.Registry == nil {
|
|
refuse("Domains aren't available right now.", "")
|
|
return
|
|
}
|
|
|
|
// Classify before claiming, so the member's own pending claim can be
|
|
// named and linked. Every other refusal collapses to the registry's
|
|
// single message — a member must not learn who holds a name, or that
|
|
// anyone is verifying it.
|
|
av, err := h.Registry.CheckAvailability(ctx, root, session.WorkspaceID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to check domain availability", slog.Any("error", err))
|
|
refuse("We couldn't check that domain. Please try again.", "")
|
|
return
|
|
}
|
|
switch {
|
|
case av.Verdict != domains.VerdictAvailable:
|
|
if av.Claim != nil && av.Claim.WorkspaceID == session.WorkspaceID && av.Claim.Status == domains.StatusPending {
|
|
refuse("You're already verifying that domain.", av.Claim.ClaimID)
|
|
return
|
|
}
|
|
refuse(av.MemberMessage(), "")
|
|
return
|
|
case av.Claim == nil:
|
|
// Free: claim it below.
|
|
case av.Claim.Kind == domains.KindOperatorRoot:
|
|
refuse("That domain is one of ours — create a site with a site name instead of a domain you own.", "")
|
|
return
|
|
default:
|
|
// Inside the caller's own ACTIVE claim: a name there is a placement,
|
|
// not a second claim, and needs no verification at all.
|
|
refuse("You already have that domain. Any name inside it is yours — create a site with it from your dashboard.", "")
|
|
return
|
|
}
|
|
|
|
claim, message := h.startClaimVerification(ctx, session.WorkspaceID, root)
|
|
if claim == nil {
|
|
refuse(message, "")
|
|
return
|
|
}
|
|
h.renderClaimStatus(w, r, *claim, "Waiting for your DNS records…")
|
|
}
|
|
|
|
// startClaimVerification creates the pending external claim and starts its
|
|
// verification workflow. The sequence mirrors FedWiki's create glue exactly
|
|
// (internal/integrations/fedwiki/web/customdomain.go): the registry
|
|
// re-evaluates the name under its own lock, so a lost race surfaces as a
|
|
// refusal rather than a corrupt claim, and a workflow that fails to start
|
|
// frees the name again instead of parking it for the whole window. Returns
|
|
// the claim, or a member-facing rejection message.
|
|
func (h *MemberDomainsHandler) startClaimVerification(ctx context.Context, workspaceID, root string) (*domains.Claim, string) {
|
|
if h.TemporalClient == nil {
|
|
h.Logger.Error("Temporal client not configured")
|
|
return nil, "Domain verification is not available right now."
|
|
}
|
|
|
|
claim, err := h.Registry.ClaimExternal(ctx, workspaceID, root)
|
|
if err != nil {
|
|
return nil, h.claimErrorMessage(err)
|
|
}
|
|
|
|
input := wfdomains.VerifyClaimWorkflowInput{
|
|
ClaimID: claim.ClaimID,
|
|
WorkspaceID: workspaceID,
|
|
Root: claim.RootFqdn,
|
|
Token: claim.Token,
|
|
ConnectTarget: h.ConnectTarget,
|
|
ExpiresAt: claim.ExpiresAt.Time,
|
|
}
|
|
options := client.StartWorkflowOptions{
|
|
ID: wfdomains.VerifyClaimWorkflowIDPrefix + claim.ClaimID,
|
|
TaskQueue: queues.Main,
|
|
}
|
|
if _, err := h.TemporalClient.ExecuteWorkflow(ctx, options, wfdomains.VerifyClaimWorkflow, input); err != nil {
|
|
h.Logger.Error("failed to start claim verification workflow", slog.Any("error", err))
|
|
// Free the name: a pending claim nothing is polling for would hold it
|
|
// for the full window. `canceled`, not `expired` — the window did not
|
|
// elapse — and the mark is pending-guarded, so it cannot disturb a
|
|
// claim that somehow raced to active. The SYSTEM cancel: this is our
|
|
// failure, so it must not debit the member's abandonment ledger.
|
|
if _, cErr := h.Registry.CancelClaimSystem(ctx, workspaceID, claim.ClaimID); cErr != nil {
|
|
h.Logger.Error("failed to cancel orphaned claim", slog.Any("error", cErr))
|
|
}
|
|
return nil, "We couldn't start verification. Please try again."
|
|
}
|
|
return &claim, ""
|
|
}
|
|
|
|
// claimErrorMessage collapses a registry allocation error to what a member may
|
|
// read. Only the shape and resource-guard errors carry their own reason; every
|
|
// namespace refusal renders the registry's single indistinguishable message.
|
|
func (h *MemberDomainsHandler) claimErrorMessage(err error) string {
|
|
var unavailable *domains.Unavailable
|
|
var budget *domains.BudgetExceeded
|
|
switch {
|
|
case errors.As(err, &unavailable):
|
|
return unavailable.MemberMessage()
|
|
case errors.As(err, &budget):
|
|
// The documented exception to the collapse rule: the abandonment and
|
|
// initiation budgets report the caller's OWN history and retry time,
|
|
// which discloses nothing about any other workspace and is the only
|
|
// thing the member can act on (design D3).
|
|
return budget.MemberMessage()
|
|
case errors.Is(err, domains.ErrPendingClaimCap):
|
|
return "You already have several domain verifications in progress. " +
|
|
"Cancel one, or wait for one to finish, before starting another."
|
|
case errors.Is(err, domains.ErrInvalidName):
|
|
// The wrapped text IS the member-facing shape message from
|
|
// internal/dnsname (the member typed the name; its shape is not a
|
|
// secret).
|
|
return strings.TrimPrefix(err.Error(), domains.ErrInvalidName.Error()+": ")
|
|
case errors.Is(err, domains.ErrInsideOperatorRoot):
|
|
return "That domain is one of ours — create a site with a site name instead of a domain you own."
|
|
case errors.Is(err, domains.ErrAlreadyInsideClaim):
|
|
return "You already have that domain. Create your site with it from your dashboard."
|
|
default:
|
|
h.Logger.Error("failed to create external domain claim", slog.Any("error", err))
|
|
return "We couldn't start verification. Please try again."
|
|
}
|
|
}
|
|
|
|
// CheckClaimNow handles POST /partials/domains/claims/{claimID}/check —
|
|
// signals the claim's verification workflow to probe immediately instead of
|
|
// waiting out its backoff. Best-effort: a signal failure (e.g. the workflow
|
|
// just finished) still renders the current state, and the workflow's own
|
|
// signal drain coalesces rapid clicks.
|
|
func (h *MemberDomainsHandler) CheckClaimNow(w http.ResponseWriter, r *http.Request) {
|
|
claim, ok := h.ownedClaim(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if claim.Status == domains.StatusPending && h.TemporalClient != nil {
|
|
if err := h.TemporalClient.SignalWorkflow(r.Context(),
|
|
wfdomains.VerifyClaimWorkflowIDPrefix+claim.ClaimID, "",
|
|
wfdomains.CheckNowSignal, nil); err != nil {
|
|
h.Logger.Warn("failed to signal check-now", slog.Any("error", err))
|
|
}
|
|
}
|
|
h.renderClaimStatus(w, r, claim, "Checking your DNS records now…")
|
|
}
|
|
|
|
// CancelClaim handles POST /partials/domains/claims/{claimID}/cancel. The
|
|
// claim is marked first — canceled frees the name the moment the update
|
|
// commits — and the workflow is then canceled best-effort. Zero rows updated
|
|
// means the cancel lost its race to verification or expiry, and the member
|
|
// simply sees the claim's current state (spec: "Cancel loses the race to
|
|
// verification").
|
|
func (h *MemberDomainsHandler) CancelClaim(w http.ResponseWriter, r *http.Request) {
|
|
claim, ok := h.ownedClaim(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
canceled, err := h.Registry.CancelClaim(r.Context(), claim.WorkspaceID, claim.ClaimID)
|
|
if err != nil {
|
|
h.Logger.Error("failed to cancel claim", slog.Any("error", err))
|
|
h.renderClaimStatus(w, r, claim, "We couldn't cancel this verification. Please try again.")
|
|
return
|
|
}
|
|
if canceled {
|
|
if h.TemporalClient != nil {
|
|
if cErr := h.TemporalClient.CancelWorkflow(r.Context(),
|
|
wfdomains.VerifyClaimWorkflowIDPrefix+claim.ClaimID, ""); cErr != nil {
|
|
// The claim is already terminal; an orphaned poller records
|
|
// nothing (probe writes are pending-guarded) and expires on
|
|
// its own.
|
|
h.Logger.Warn("failed to cancel verification workflow", slog.Any("error", cErr))
|
|
}
|
|
}
|
|
// The update committed — the outcome is known without a re-read, so a
|
|
// transient read failure can't render a stale still-pending view.
|
|
claim.Status = domains.StatusCanceled
|
|
h.renderClaimStatus(w, r, claim, "")
|
|
return
|
|
}
|
|
// Zero rows: the cancel lost a race to verified/expired — show whatever
|
|
// the claim is now.
|
|
if fresh, fErr := h.Registry.ClaimByID(r.Context(), claim.WorkspaceID, claim.ClaimID); fErr == nil {
|
|
claim = fresh
|
|
}
|
|
h.renderClaimStatus(w, r, claim, "")
|
|
}
|
|
|
|
// ReleaseClaim handles POST /partials/domains/claims/{claimID}/release —
|
|
// giving up a claim the workspace no longer wants. The registry refuses while
|
|
// any placement remains (spec: "Release is guarded by placements"); this
|
|
// handler turns that refusal into the actionable message the member needs.
|
|
func (h *MemberDomainsHandler) ReleaseClaim(w http.ResponseWriter, r *http.Request) {
|
|
session, ok := h.session(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.Registry == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
claimID := r.PathValue("claimID")
|
|
|
|
err := h.Registry.ReleaseClaim(ctx, session.WorkspaceID, claimID)
|
|
if errors.Is(err, domains.ErrNotFound) {
|
|
// Foreign or nonexistent — the same answer for both.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
data := h.surfaceData(ctx, session.WorkspaceID)
|
|
switch {
|
|
case err == nil:
|
|
data.Notice = "Domain released. It's free to claim again."
|
|
case errors.Is(err, domains.ErrClaimHasPlacements):
|
|
data.Error = h.placementGuardMessage(ctx, claimID)
|
|
case errors.Is(err, domains.ErrClaimNotReleasable):
|
|
data.Error = "That domain is part of our hosting and can't be released."
|
|
case errors.Is(err, domains.ErrClaimNotActive):
|
|
data.Error = "That domain isn't active — cancel its verification instead."
|
|
default:
|
|
h.Logger.Error("failed to release claim", slog.Any("error", err))
|
|
data.Error = "We couldn't release that domain. Please try again."
|
|
}
|
|
h.Templates.Render(w, "member_domains.html", data)
|
|
}
|
|
|
|
// placementGuardMessage explains a refused release by naming the names that
|
|
// still sit inside the claim, so "remove the sites first" is actionable
|
|
// rather than a riddle. The list is the member's own; nothing here can leak
|
|
// another workspace's names, since the release already proved ownership.
|
|
func (h *MemberDomainsHandler) placementGuardMessage(ctx context.Context, claimID string) string {
|
|
const base = "That domain still has sites on it. Remove them first, then release the domain."
|
|
if h.DomainsQ == nil {
|
|
return base
|
|
}
|
|
placements, err := h.DomainsQ.ListPlacementsByClaim(ctx, claimID)
|
|
if err != nil || len(placements) == 0 {
|
|
return base
|
|
}
|
|
const maxNamed = 3
|
|
names := make([]string, 0, maxNamed+1)
|
|
for i, placement := range placements {
|
|
if i == maxNamed {
|
|
names = append(names, "…")
|
|
break
|
|
}
|
|
names = append(names, placement.Fqdn)
|
|
}
|
|
return "That domain still serves " + strings.Join(names, ", ") +
|
|
". Remove those sites first, then release the domain."
|
|
}
|