From 0affda70bdcbb02b2498b2c0aac5ae2e1abbeff9 Mon Sep 17 00:00:00 2001 From: Christian Galo Date: Sun, 26 Jul 2026 03:33:16 -0500 Subject: [PATCH] Centralize the external-claim entitlement gate in the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/domains/external_gate_test.go | 38 +++++++++ internal/domains/registry.go | 58 +++++++++++-- internal/integrations/fedwiki/fedwiki.go | 11 ++- internal/integrations/fedwiki/web/api.go | 10 ++- .../integrations/fedwiki/web/customdomain.go | 59 ++++++-------- internal/integrations/fedwiki/web/partials.go | 18 +++-- internal/server/external_claim_gate.go | 64 +++++++++++++++ internal/server/external_claim_gate_test.go | 60 ++++++++++++++ internal/server/member_domains.go | 81 +++++++------------ internal/server/server.go | 8 +- .../.openspec.yaml | 2 + .../design.md | 73 +++++++++++++++++ .../proposal.md | 62 ++++++++++++++ .../specs/domains-registry/spec.md | 62 ++++++++++++++ .../tasks.md | 24 ++++++ openspec/specs/domains-registry/spec.md | 36 ++++++--- status/issues.md | 45 +++++++---- 17 files changed, 583 insertions(+), 128 deletions(-) create mode 100644 internal/domains/external_gate_test.go create mode 100644 internal/server/external_claim_gate.go create mode 100644 internal/server/external_claim_gate_test.go create mode 100644 openspec/changes/archive/2026-07-26-centralize-external-claim-gate/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-26-centralize-external-claim-gate/design.md create mode 100644 openspec/changes/archive/2026-07-26-centralize-external-claim-gate/proposal.md create mode 100644 openspec/changes/archive/2026-07-26-centralize-external-claim-gate/specs/domains-registry/spec.md create mode 100644 openspec/changes/archive/2026-07-26-centralize-external-claim-gate/tasks.md diff --git a/internal/domains/external_gate_test.go b/internal/domains/external_gate_test.go new file mode 100644 index 0000000..280cea6 --- /dev/null +++ b/internal/domains/external_gate_test.go @@ -0,0 +1,38 @@ +package domains + +import ( + "context" + "errors" + "testing" +) + +// TestClaimExternalGateRefusesBeforeAllocating pins the enforcement point +// (centralize-external-claim-gate): a refusing gate stops ClaimExternal +// before ANY registry work. The registry here has a nil database — reaching +// the lock or the queries would panic — so the clean typed refusal is proof +// nothing was allocated, no token minted, no lock taken. +func TestClaimExternalGateRefusesBeforeAllocating(t *testing.T) { + for _, refusal := range []error{ErrExternalClaimsNotEntitled, ErrNoConnectTarget} { + r := NewRegistry(nil, WithExternalClaimGate(func(ctx context.Context, workspaceID string) error { + return refusal + })) + _, err := r.ClaimExternal(context.Background(), "ws-1", "example.org") + if !errors.Is(err, refusal) { + t.Errorf("ClaimExternal with refusing gate: got %v, want %v", err, refusal) + } + } +} + +// TestClaimExternalGateReceivesWorkspace pins that the gate is consulted +// with the requesting workspace — the input the entitlement lookup keys on. +func TestClaimExternalGateReceivesWorkspace(t *testing.T) { + var saw string + r := NewRegistry(nil, WithExternalClaimGate(func(ctx context.Context, workspaceID string) error { + saw = workspaceID + return ErrExternalClaimsNotEntitled // refuse so the nil DB is never touched + })) + _, _ = r.ClaimExternal(context.Background(), "ws-42", "example.org") + if saw != "ws-42" { + t.Errorf("gate saw workspace %q, want ws-42", saw) + } +} diff --git a/internal/domains/registry.go b/internal/domains/registry.go index f57975d..207ce10 100644 --- a/internal/domains/registry.go +++ b/internal/domains/registry.go @@ -346,6 +346,9 @@ type Registry struct { db *sql.DB q Querier policy Policy + // externalGate, when set, must admit a workspace before ClaimExternal + // allocates anything. See WithExternalClaimGate. + externalGate ExternalClaimGate } // RegistryOption customizes a Registry at construction. @@ -358,6 +361,41 @@ func WithPolicy(policy Policy) RegistryOption { return func(r *Registry) { r.policy = policy } } +// ExternalClaimGate decides whether a workspace may initiate an external +// claim. It returns nil to admit, ErrExternalClaimsNotEntitled or +// ErrNoConnectTarget (possibly wrapped) to refuse with a nameable reason. +// The type lives here so the registry can enforce the gate without importing +// the entitlement system — the concrete gate is injected by the wiring that +// owns those dependencies (centralize-external-claim-gate). +type ExternalClaimGate func(ctx context.Context, workspaceID string) error + +// Typed gate refusals: surfaces map these to member-facing copy with +// errors.Is. A gate refusal names the workspace's own plan state, so unlike +// foreign-claim conflicts it is not an oracle risk. +var ( + // ErrExternalClaimsNotEntitled: the workspace's primary pool does not + // grant ExternalClaimsResourceKey. + ErrExternalClaimsNotEntitled = errors.New("domains: external claims not entitled") + // ErrNoConnectTarget: the deployment has no connect target configured, + // so external domains cannot be served regardless of plan. + ErrNoConnectTarget = errors.New("domains: no connect target configured") +) + +// ExternalClaimsResourceKey is the boolean entitlement gating external +// claims. Platform-owned: seeded by the domains migration stream (kind +// boolean, provider NULL). Named here, once — gate implementations and +// surfaces take it from this constant. +const ExternalClaimsResourceKey = "external_domain_claims" + +// WithExternalClaimGate attaches the entitlement gate ClaimExternal enforces +// before allocating. Member-facing registry constructions MUST set it; a nil +// gate admits, which is the correct default for the member-agnostic +// contexts that also build registries (boot reconciliation, census +// adoption, unit tests) and were never plan-gated. +func WithExternalClaimGate(gate ExternalClaimGate) RegistryOption { + return func(r *Registry) { r.externalGate = gate } +} + // NewRegistry builds a Registry over a pooled database handle. func NewRegistry(database *sql.DB, opts ...RegistryOption) *Registry { r := &Registry{db: database} @@ -695,12 +733,18 @@ func (t *Tx) claimCarved(ctx context.Context, workspaceID, fqdn string, policy n // (ExternalClaimWindow unless the policy says otherwise); the caller starts // the verification workflow from the returned row. // -// The entitlement gate is the CALLER's: the registry reads no entitlement -// table (that would invert the module dependency and put a second lock order -// in play). Callers must confirm the workspace's primary pool grants -// `external_domain_claims` and that a connect target is configured before -// calling. +// The entitlement gate is enforced HERE, via the injected +// ExternalClaimGate, so every entry point inherits it +// (centralize-external-claim-gate). The registry still reads no entitlement +// table itself, and the gate runs before the advisory lock is taken, so no +// second lock order enters play — the two objections the old +// caller-obligation comment recorded. func (r *Registry) ClaimExternal(ctx context.Context, workspaceID, root string) (Claim, error) { + if r.externalGate != nil { + if err := r.externalGate(ctx, workspaceID); err != nil { + return Claim{}, err + } + } var out Claim err := r.WithLock(ctx, func(ctx context.Context, tx *Tx) error { var err error @@ -710,7 +754,9 @@ func (r *Registry) ClaimExternal(ctx context.Context, workspaceID, root string) return out, err } -// ClaimExternal creates the pending claim under an already-held lock. +// ClaimExternal creates the pending claim under an already-held lock. Gate +// enforcement lives on the Registry wrapper (pre-lock); under-lock callers +// are registry-internal. func (t *Tx) ClaimExternal(ctx context.Context, workspaceID, root string) (Claim, error) { name := dnsname.Normalize(root) if err := externalClaimPreflight(ctx, t.q, workspaceID, name); err != nil { diff --git a/internal/integrations/fedwiki/fedwiki.go b/internal/integrations/fedwiki/fedwiki.go index d6127bc..c948636 100644 --- a/internal/integrations/fedwiki/fedwiki.go +++ b/internal/integrations/fedwiki/fedwiki.go @@ -185,8 +185,13 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error { // how RegisterWorkflows constructs the activities' registry. The claim // policy comes from Deps rather than viper: it is core configuration, and // a claim initiated from this surface must obey the same window, caps, and - // ledger as one initiated from core's own Domains page. - registry := domains.NewRegistry(deps.Database, domains.WithPolicy(deps.DomainsPolicy)) + // ledger as one initiated from core's member claim flow. This registry + // serves member-initiated external claims, so it MUST carry the shared + // entitlement gate (centralize-external-claim-gate); the same gate value + // drives the handlers' affordance rendering below. + customDomainsGate := server.NewExternalClaimGate(deps.EntitlementsQ, customDomainTarget) + registry := domains.NewRegistry(deps.Database, domains.WithPolicy(deps.DomainsPolicy), + domains.WithExternalClaimGate(customDomainsGate)) apiHandler := web.NewFedWikiHandler(web.FedWikiHandlerConfig{ SiteQ: siteQ, @@ -199,6 +204,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error { FedWikiAllowedDomains: allowedDomains, FedWikiSiteScheme: siteScheme, CustomDomainTarget: customDomainTarget, + CustomDomainsGate: customDomainsGate, SupportURL: supportURL, }) apiHandler.RegisterRoutes(mux) @@ -214,6 +220,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error { FedWikiAllowedDomains: allowedDomains, FedWikiSiteScheme: siteScheme, CustomDomainTarget: customDomainTarget, + CustomDomainsGate: customDomainsGate, SupportURL: supportURL, SwapCooldown: viper.GetDuration("fedwiki-swap-cooldown"), TemplatesFS: templatesFS(), diff --git a/internal/integrations/fedwiki/web/api.go b/internal/integrations/fedwiki/web/api.go index da9534f..f77e0cd 100644 --- a/internal/integrations/fedwiki/web/api.go +++ b/internal/integrations/fedwiki/web/api.go @@ -46,7 +46,11 @@ type FedWikiHandler struct { FedWikiAllowedDomains []string // Domains where users can create sites FedWikiSiteScheme string // http or https CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow - SupportURL string + // CustomDomainsGate is the shared external-claim gate + // (centralize-external-claim-gate); affordance rendering only — the + // registry enforces the same gate at ClaimExternal. + CustomDomainsGate domains.ExternalClaimGate + SupportURL string } // FedWikiHandlerConfig holds configuration for creating a FedWikiHandler. @@ -61,6 +65,7 @@ type FedWikiHandlerConfig struct { FedWikiAllowedDomains []string FedWikiSiteScheme string CustomDomainTarget string + CustomDomainsGate domains.ExternalClaimGate SupportURL string } @@ -77,6 +82,7 @@ func NewFedWikiHandler(cfg FedWikiHandlerConfig) *FedWikiHandler { FedWikiAllowedDomains: cfg.FedWikiAllowedDomains, FedWikiSiteScheme: cfg.FedWikiSiteScheme, CustomDomainTarget: cfg.CustomDomainTarget, + CustomDomainsGate: cfg.CustomDomainsGate, SupportURL: cfg.SupportURL, } } @@ -248,7 +254,7 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, userMsg, h.SupportURL) return case dispositionUnclaimed: - enabled, err := customDomainsEnabled(r.Context(), h.EntitlementsQ, session.WorkspaceID, h.CustomDomainTarget) + enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID) if err != nil { h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err)) respondError(w, http.StatusInternalServerError, "Failed to check your plan. Please try again.", h.SupportURL) diff --git a/internal/integrations/fedwiki/web/customdomain.go b/internal/integrations/fedwiki/web/customdomain.go index b7b92a8..59fad26 100644 --- a/internal/integrations/fedwiki/web/customdomain.go +++ b/internal/integrations/fedwiki/web/customdomain.go @@ -15,7 +15,6 @@ package web import ( "context" - "database/sql" "errors" "log/slog" "net/http" @@ -23,49 +22,31 @@ import ( "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" - "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" 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" ) -// customDomainsResourceKey is the boolean entitlement gating external-domain -// claims. It is platform-owned, not fedwiki-owned: domains-registry D7 seeds -// `external_domain_claims` from the domains stream, and fedwiki 00003 -// re-points existing conferrals onto it and deletes the old -// `fedwiki_custom_domains` key. -const customDomainsResourceKey = "external_domain_claims" - -// customDomainsEnabled reports whether the workspace may create external -// domain claims: the pool must hold a granted boolean entitlement for -// external_domain_claims AND the operator must have configured -// fedwiki-custom-domain-target. No target means no serving path to point -// members at, so the affordance stays hidden even when entitled (the -// dead-end-checkbox failure mode audit finding #5 flagged). -func customDomainsEnabled(ctx context.Context, entQ entitlements.Querier, workspaceID, connectTarget string) (bool, error) { - if strings.TrimSpace(connectTarget) == "" { +// customDomainsEnabled is the affordance read over the injected gate +// (centralize-external-claim-gate): fedwiki no longer names the entitlement +// key or reads the entitlement tables — the same gate the registry enforces +// at ClaimExternal decides here, so the affordance can never disagree with +// the enforcement (the dead-end-checkbox failure mode audit finding #5 +// flagged). A typed refusal is simply "not enabled"; only infrastructure +// failures return an error. +func customDomainsEnabled(ctx context.Context, gate domains.ExternalClaimGate, workspaceID string) (bool, error) { + if gate == nil { return false, nil } - assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } + err := gate(ctx, workspaceID) + switch { + case err == nil: + return true, nil + case errors.Is(err, domains.ErrExternalClaimsNotEntitled), errors.Is(err, domains.ErrNoConnectTarget): + return false, nil + default: return false, err } - ent, err := entQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{ - PoolID: assignment.PoolID, - ResourceKey: customDomainsResourceKey, - }) - if err != nil { - // Never-conferred keys have no row (entitlements spec) — that is - // simply "not granted", not an error. - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - return ent.Granted, nil } // customDomainDisposition is the branch table both create endpoints follow @@ -227,6 +208,14 @@ func claimErrorMessage(err error, logger *slog.Logger) string { // 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.ErrExternalClaimsNotEntitled): + // Defense in depth: the affordance pre-check runs the same gate, so a + // member normally never reaches the registry unentitled — but the + // registry enforces it regardless (centralize-external-claim-gate) + // and its refusal deserves the same copy. + return "Adding a domain you own isn't part of your current plan." + case errors.Is(err, domains.ErrNoConnectTarget): + return "Domains you own can't be connected on this deployment yet." 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): diff --git a/internal/integrations/fedwiki/web/partials.go b/internal/integrations/fedwiki/web/partials.go index acbd0bc..b4652ec 100644 --- a/internal/integrations/fedwiki/web/partials.go +++ b/internal/integrations/fedwiki/web/partials.go @@ -40,9 +40,13 @@ type FedWikiPartialsHandler struct { FedWikiAllowedDomains []string // Domains where users can create sites FedWikiSiteScheme string // http or https CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow - SupportURL string - SwapCooldown time.Duration // active-site rotation cooldown window - Templates *server.SafeTemplates + // CustomDomainsGate is the shared external-claim gate + // (centralize-external-claim-gate): the same func the registry enforces + // at ClaimExternal, used here only to decide affordance rendering. + CustomDomainsGate domains.ExternalClaimGate + SupportURL string + SwapCooldown time.Duration // active-site rotation cooldown window + Templates *server.SafeTemplates } // FedWikiPartialsConfig holds configuration for the FedWiki partials handler. @@ -57,6 +61,7 @@ type FedWikiPartialsConfig struct { FedWikiAllowedDomains []string FedWikiSiteScheme string CustomDomainTarget string + CustomDomainsGate domains.ExternalClaimGate SupportURL string SwapCooldown time.Duration // TemplatesFS is FedWiki's own template directory (fedwiki_*.html), @@ -102,6 +107,7 @@ func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandl FedWikiAllowedDomains: cfg.FedWikiAllowedDomains, FedWikiSiteScheme: cfg.FedWikiSiteScheme, CustomDomainTarget: cfg.CustomDomainTarget, + CustomDomainsGate: cfg.CustomDomainsGate, SupportURL: cfg.SupportURL, SwapCooldown: cfg.SwapCooldown, Templates: server.NewSafeTemplates(tmpl, cfg.Logger), @@ -354,7 +360,7 @@ func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Re count = q.currentUsage } - customDomains, cdErr := customDomainsEnabled(r.Context(), h.EntitlementsQ, workspaceID, h.CustomDomainTarget) + customDomains, cdErr := customDomainsEnabled(r.Context(), h.CustomDomainsGate, workspaceID) if cdErr != nil { h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr)) } @@ -428,7 +434,7 @@ func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Reque h.renderCreateFormError(w, session.WorkspaceID, userMsg) return case dispositionUnclaimed: - enabled, err := customDomainsEnabled(r.Context(), h.EntitlementsQ, session.WorkspaceID, h.CustomDomainTarget) + enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID) if err != nil { h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err)) h.renderCreateFormError(w, session.WorkspaceID, "We couldn't check your plan right now. Please try again.") @@ -1096,7 +1102,7 @@ func (h *FedWikiPartialsHandler) renderCreateFormError(w http.ResponseWriter, wo count = q.currentUsage } - customDomains, cdErr := customDomainsEnabled(context.TODO(), h.EntitlementsQ, workspaceID, h.CustomDomainTarget) + customDomains, cdErr := customDomainsEnabled(context.TODO(), h.CustomDomainsGate, workspaceID) if cdErr != nil { h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr)) } diff --git a/internal/server/external_claim_gate.go b/internal/server/external_claim_gate.go new file mode 100644 index 0000000..1802d84 --- /dev/null +++ b/internal/server/external_claim_gate.go @@ -0,0 +1,64 @@ +// The one concrete ExternalClaimGate (centralize-external-claim-gate): +// built here because this package owns both dependencies the gate needs — +// the entitlements querier and the deployment's connect target. Injected +// into every member-facing domains.Registry (enforcement) and handed to the +// surfaces that render the affordance (presentation), so the two conditions +// are decided in exactly one place. +package server + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" + "git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements" +) + +// NewExternalClaimGate builds the gate ClaimExternal enforces: the +// deployment must have a connect target (no target ⇒ no serving path, +// regardless of plan) AND the workspace's primary active pool must grant +// domains.ExternalClaimsResourceKey. A never-conferred key has no row +// (entitlements spec) and reads as not granted, not as an error; only +// infrastructure failures return a non-refusal error. +func NewExternalClaimGate(entQ entitlements.Querier, connectTarget string) domains.ExternalClaimGate { + connectTarget = strings.TrimSpace(connectTarget) + return func(ctx context.Context, workspaceID string) error { + if connectTarget == "" { + return domains.ErrNoConnectTarget + } + if entQ == nil { + return domains.ErrExternalClaimsNotEntitled + } + assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domains.ErrExternalClaimsNotEntitled + } + return fmt.Errorf("external-claim gate: primary pool for %s: %w", workspaceID, err) + } + ent, err := entQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{ + PoolID: assignment.PoolID, + ResourceKey: domains.ExternalClaimsResourceKey, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domains.ErrExternalClaimsNotEntitled + } + return fmt.Errorf("external-claim gate: boolean entitlement for %s: %w", workspaceID, err) + } + if !ent.Granted { + return domains.ErrExternalClaimsNotEntitled + } + return nil + } +} + +// externalClaimGateRefused reports whether err is one of the gate's typed +// refusals (as opposed to an infrastructure failure). +func externalClaimGateRefused(err error) bool { + return errors.Is(err, domains.ErrExternalClaimsNotEntitled) || + errors.Is(err, domains.ErrNoConnectTarget) +} diff --git a/internal/server/external_claim_gate_test.go b/internal/server/external_claim_gate_test.go new file mode 100644 index 0000000..37e518d --- /dev/null +++ b/internal/server/external_claim_gate_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "git.coopcloud.tech/wiki-cafe/member-console/internal/domains" +) + +// TestNewExternalClaimGateRefusals pins the gate's two refusal conditions in +// the order they short-circuit: no connect target refuses regardless of +// entitlement state (no serving path exists), and an absent entitlement +// source reads as not granted, never as an error. +func TestNewExternalClaimGateRefusals(t *testing.T) { + noTarget := NewExternalClaimGate(nil, " ") + if err := noTarget(context.Background(), "ws-1"); !errors.Is(err, domains.ErrNoConnectTarget) { + t.Errorf("empty connect target: got %v, want ErrNoConnectTarget", err) + } + + noEntQ := NewExternalClaimGate(nil, "connect.example.test") + if err := noEntQ(context.Background(), "ws-1"); !errors.Is(err, domains.ErrExternalClaimsNotEntitled) { + t.Errorf("nil entitlements querier: got %v, want ErrExternalClaimsNotEntitled", err) + } +} + +// TestExternalClaimGateRefusedClassification keeps the refusal/infrastructure +// distinction honest — surfaces close the affordance on refusals but must +// surface real failures. +func TestExternalClaimGateRefusedClassification(t *testing.T) { + if !externalClaimGateRefused(domains.ErrExternalClaimsNotEntitled) || + !externalClaimGateRefused(domains.ErrNoConnectTarget) { + t.Error("typed refusals must classify as refused") + } + if externalClaimGateRefused(errors.New("connection reset")) { + t.Error("an infrastructure error must not classify as a refusal") + } + if externalClaimGateRefused(nil) { + t.Error("nil is admission, not refusal") + } +} + +// TestMemberDomainsHandlerCarriesGate pins the constructor wiring: a member +// domains handler always holds a gate (built from its own config), so the +// affordance read can never dereference nil. The registry-side injections in +// server.go and fedwiki.go are exercised end-to-end by the walkthroughs. +func TestMemberDomainsHandlerCarriesGate(t *testing.T) { + h, err := NewMemberDomainsHandler(MemberDomainsConfig{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + if err != nil { + t.Fatalf("construct handler: %v", err) + } + if h.gate == nil { + t.Fatal("member domains handler constructed without a gate") + } + if err := h.gate(context.Background(), "ws-1"); !errors.Is(err, domains.ErrNoConnectTarget) { + t.Errorf("gate with empty config: got %v, want ErrNoConnectTarget", err) + } +} diff --git a/internal/server/member_domains.go b/internal/server/member_domains.go index 2b0c37e..47c8fff 100644 --- a/internal/server/member_domains.go +++ b/internal/server/member_domains.go @@ -19,7 +19,6 @@ package server import ( "context" - "database/sql" "errors" "html/template" "io/fs" @@ -38,12 +37,6 @@ import ( "go.temporal.io/sdk/client" ) -// externalDomainClaimsResourceKey is the boolean entitlement gating external -// domain claims. It is platform-owned (domains-registry D7 seeds it from the -// domains migration stream), which is why this surface names it directly -// rather than asking an integration. -const externalDomainClaimsResourceKey = "external_domain_claims" - // 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. @@ -64,14 +57,19 @@ type MemberDomainsHandler struct { // surface goes through it, so the ownership and disjointness rules are // enforced in exactly one place. Registry *domains.Registry - EntitlementsQ entitlements.Querier 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). + // 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. @@ -102,12 +100,12 @@ func NewMemberDomainsHandler(cfg MemberDomainsConfig) (*MemberDomainsHandler, er return &MemberDomainsHandler{ DomainsQ: cfg.DomainsQ, Registry: cfg.Registry, - EntitlementsQ: cfg.EntitlementsQ, 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 } @@ -283,41 +281,20 @@ func splitProbeObserved(joined string) []string { // --- Reads --- -// externalClaimsGranted reports whether the workspace's primary pool grants -// external_domain_claims. A never-conferred key has no row (entitlements -// spec), which is simply "not granted" rather than an error. -func (h *MemberDomainsHandler) externalClaimsGranted(ctx context.Context, workspaceID string) (bool, error) { - if h.EntitlementsQ == nil { - return false, nil - } - assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - ent, err := h.EntitlementsQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{ - PoolID: assignment.PoolID, - ResourceKey: externalDomainClaimsResourceKey, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - return ent.Granted, nil -} - -// externalClaimsEnabled is the full gate the affordance renders on: a connect -// target must be configured (no target means no serving path to point members -// at) AND the workspace must be entitled (design D7). +// 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) { - if h.ConnectTarget == "" { + err := h.gate(ctx, workspaceID) + switch { + case err == nil: + return true, nil + case externalClaimGateRefused(err): return false, nil + default: + return false, err } - return h.externalClaimsGranted(ctx, workspaceID) } // surfaceData assembles the whole Domains surface: the gate plus the @@ -503,23 +480,23 @@ func (h *MemberDomainsHandler) AddClaim(w http.ResponseWriter, r *http.Request) h.Templates.Render(w, "member_domains.html", data) } - // Server-side gate. The two halves get different copy because only one of - // them is the member's to fix. - if h.ConnectTarget == "" { + // 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 - } - granted, err := h.externalClaimsGranted(ctx, session.WorkspaceID) - if err != nil { + 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 !granted { - refuse(planGateMessage, "") - return - } if root == "" { refuse("Enter the domain you want to use.", "") return diff --git a/internal/server/server.go b/internal/server/server.go index 494263e..be9f72b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -416,8 +416,12 @@ func Start(ctx context.Context, cfg Config) error { // deployment-wide, and a future core `domains-connect-target` key // supersedes it without touching this handler. memberDomainsHandler, err := NewMemberDomainsHandler(MemberDomainsConfig{ - DomainsQ: cfg.DomainsQ, - Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy)), + DomainsQ: cfg.DomainsQ, + // Member-facing registry: MUST carry the external-claim gate so + // ClaimExternal enforces the plan gate itself + // (centralize-external-claim-gate). + Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy), + domains.WithExternalClaimGate(NewExternalClaimGate(cfg.EntitlementsQ, viper.GetString("fedwiki-custom-domain-target")))), EntitlementsQ: cfg.EntitlementsQ, TemporalClient: cfg.TemporalClient, AuthConfig: authConfig, diff --git a/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/.openspec.yaml b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/.openspec.yaml new file mode 100644 index 0000000..2bc06e0 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-26 diff --git a/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/design.md b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/design.md new file mode 100644 index 0000000..b44c85a --- /dev/null +++ b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/design.md @@ -0,0 +1,73 @@ +# Design — Centralize the External-Claim Entitlement Gate + +## Context + +`Registry.ClaimExternal` is the single allocation door for external claims +(two callers: member_domains' claim start, fedwiki's custom-domain glue), +but the plan gate guarding it lives in two web-layer copies, and the +registry's doc comment makes enforcement a caller obligation. The domains +package is deliberately a leaf (imports only `dnsname`); entitlements +access cannot move into it as an import. + +## Goals / Non-Goals + +**Goals:** +- The gate is unskippable at `ClaimExternal`; new consumers inherit it. +- One implementation, one place naming the resource key. +- Zero member-visible behavior change. + +**Non-Goals:** +- Gating carved/operator/adoption claim paths — they are not + member-initiated external claims and remain governed by their own rules. +- Any change to how the entitlement is granted or materialized. + +## Decisions + +- **Injected gate func, not an entitlements import.** `ExternalClaimGate + func(ctx, workspaceID) error` is defined in `internal/domains` (needs no + imports), attached via `WithExternalClaimGate`. This is the package's + established IoC pattern (cf. the reconciler capability, the ask + fallback). Alternative — `internal/domains` importing + `internal/entitlements` — rejected: it would end the module's leaf-ness + and prejudice the extraction future for a one-func need. +- **Nil gate admits.** Raw registries (unit tests, boot reconciliation and + census adoption, workflows) construct without a gate and are unaffected. + The wiring that serves member traffic is the only place that MUST inject + it — asserted by tests on both member-facing constructions. A + fail-closed default was considered and rejected: it would gate + system-context allocation paths that are not member-initiated and never + carried this check. +- **Typed refusals.** The gate returns + `ErrExternalClaimsNotEntitled` / `ErrNoConnectTarget`; surfaces map them + to the existing plan-gate and connect-target copy via `errors.Is`. The + registry wraps neither in `ErrNotFound` semantics — a gate refusal is + named, not an oracle risk (the workspace's own entitlement state is + theirs to see). +- **Gate built in `internal/server`, shared as a value.** The constructor + (`NewExternalClaimGate(entQ, connectTarget)`) lives beside the wiring + that owns both dependencies. FedWiki's web config swaps + `EntitlementsQ` + `CustomDomainTarget` lookups for one + `CustomDomainsGate domains.ExternalClaimGate` field — fedwiki already + imports domains, so no new edge; the fedwiki→entitlements edge for this + purpose disappears. +- **Surface pre-checks stay, as derivations.** Hiding the affordance and + pre-flight error copy remain in the web layers, but call the same gate — + presentation over the one enforcement point, per the spec delta. + +## Risks / Trade-offs + +- [A registry constructed for member traffic without the gate] → covered + by wiring tests asserting the gate is present on the member-facing + constructions; nil-admits keeps the blast radius to exactly the wiring + mistake the tests pin. +- [Double evaluation (pre-flight + registry) per claim start] → two cheap + queries on a rare, human-paced action; not worth caching. + +## Migration Plan + +Pure refactor + enforcement relocation; no schema or config changes. +Rollback is a revert. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/proposal.md b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/proposal.md new file mode 100644 index 0000000..3562f86 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/proposal.md @@ -0,0 +1,62 @@ +# Centralize the External-Claim Entitlement Gate + +## Why + +The plan gate for bringing your own domain (`external_domain_claims` + +configured connect target) is enforced twice, in two web layers — fedwiki's +`customDomainsEnabled` and member_domains' `externalClaimsGranted` — while +the registry's `ClaimExternal` merely *documents* that "callers must +confirm" it. Every future door into external claims must remember to +reimplement the check, and extracting the domains system would leave its +gate behind. Filed as debt in `status/issues.md` during the 2026-07-26 +de-centering work; this change pays it off: enforcement moves to the +allocation API itself, so entry points inherit the gate instead of +copying it. + +## What Changes + +- `internal/domains` gains `ExternalClaimGate` (a `func(ctx, workspaceID) + error` type — the package stays a leaf), a `WithExternalClaimGate` + registry option, typed refusals (`ErrExternalClaimsNotEntitled`, + `ErrNoConnectTarget`), and the canonical + `ExternalClaimsResourceKey` constant. `Registry.ClaimExternal` / + `Tx.ClaimExternal` invoke the gate before allocating; a nil gate admits + (raw registries in tests and boot adoption are member-agnostic and stay + ungated by construction). +- One concrete gate, built once in `internal/server` from + `entitlements.Querier` + the connect target, is injected into every + registry construction that serves member-initiated claims, and handed to + fedwiki's web layer as a plain func for affordance rendering. +- The duplicate helpers die: fedwiki's `customDomainsEnabled` (+ its + `customDomainsResourceKey` const) and member_domains' + `externalClaimsGranted` (+ its const) are replaced by the injected gate; + surface pre-checks remain for UX (hide the affordance, name the plan + gate) but delegate to the same func, classifying the typed errors for + copy. +- No behavior change for members: same two conditions, same refusal + messages, same server-side enforcement — now unskippable at the vault. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `domains-registry`: the external-claim entitlement-gate requirement is + strengthened — enforcement SHALL live in the registry's claim-initiation + path via an injected gate that every entry point inherits; surface + checks are presentation-only and SHALL derive from the same gate. + +## Impact + +- `internal/domains/registry.go` (+ gate type/option/errors file) +- `internal/server`: gate constructor; `member_domains.go` slims; + registry constructions in `server.go` inject the gate +- `internal/integrations/fedwiki`: web config carries the gate func; + `customdomain.go` and its four `customDomainsEnabled` call sites + (`api.go`, `partials.go` ×3) switch over; fedwiki-built registries + inject the gate +- Tests: registry gains gated-refusal coverage; fedwiki/member gate tests + re-point at the shared func diff --git a/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/specs/domains-registry/spec.md b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/specs/domains-registry/spec.md new file mode 100644 index 0000000..6e05721 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/specs/domains-registry/spec.md @@ -0,0 +1,62 @@ +# domains-registry Delta + +## MODIFIED Requirements + +### Requirement: External claim creation is entitlement-gated + +Creating an `external` claim SHALL be permitted only when (a) the workspace's primary +active pool holds a `boolean_entitlements` row for `resource_key = +'external_domain_claims'` with `granted = true`, and (b) a connect target is +configured for serving. An absent `boolean_entitlements` row SHALL count as not +granted. The gate SHALL be enforced in the registry's claim-initiation path itself, +via a gate injected at registry construction: every entry point that starts an +external claim inherits the check from the allocation API and SHALL NOT need to +reimplement it. Surfaces offering external-domain entry (the FedWiki create form) +SHALL render the affordance only when the same gate admits, and a denied request +SHALL receive an error naming the failed condition (the plan gate, or the missing +connect target) derived from the gate's typed refusal. The +`external_domain_claims` resource key SHALL be seeded by the domains migration +stream (kind `boolean`, provider NULL, name unprefixed per the provider-registry +platform-key convention). Entitlement data previously attached to +`fedwiki_custom_domains` — including `core.boolean_entitlements`, +`core.entitlement_set_rules`, and any `discourse.group_mappings` rows — SHALL be +migrated to the new key and the old key removed. + +#### Scenario: Unentitled workspace cannot start verification + +- **WHEN** a workspace without the granted boolean requests an external claim +- **THEN** the request SHALL be refused server-side regardless of UI state +- **AND** the error SHALL name the plan gate + +#### Scenario: The registry itself refuses, not just the surfaces + +- **WHEN** an external-claim initiation reaches the registry's allocation API from + any entry point while the workspace lacks the granted boolean +- **THEN** the registry SHALL refuse before allocating anything +- **AND** no pending claim, token, or verification workflow SHALL be created + +#### Scenario: A new entry point inherits the gate + +- **WHEN** a new consumer starts external claims through the registry's + claim-initiation API without adding any gate code of its own +- **THEN** the entitlement and connect-target conditions SHALL still be enforced + for its requests + +#### Scenario: Entitled workspace on an unconfigured deployment sees no affordance + +- **WHEN** a workspace holds the granted boolean but no connect target is configured +- **THEN** external-domain entry points SHALL NOT be rendered +- **AND** a direct request SHALL still be refused server-side + +#### Scenario: Absent entitlement row means not granted + +- **WHEN** the workspace's pool has no `boolean_entitlements` row for + `external_domain_claims` +- **THEN** the gate SHALL treat the capability as not granted (no error) + +#### Scenario: Existing rules survive the key migration + +- **WHEN** migrations run on a database whose entitlement sets and pools referenced + `fedwiki_custom_domains` +- **THEN** those rules and entitlements SHALL reference `external_domain_claims` + afterward, with `fedwiki_custom_domains` removed from `core.resource_keys` diff --git a/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/tasks.md b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/tasks.md new file mode 100644 index 0000000..47a9940 --- /dev/null +++ b/openspec/changes/archive/2026-07-26-centralize-external-claim-gate/tasks.md @@ -0,0 +1,24 @@ +# Tasks — Centralize the External-Claim Entitlement Gate + +## 1. Domains package: the gate seam + +- [x] 1.1 Add `ExternalClaimGate` func type, `ErrExternalClaimsNotEntitled`, `ErrNoConnectTarget`, `ExternalClaimsResourceKey`, and the `WithExternalClaimGate` registry option +- [x] 1.2 Enforce the gate in `ClaimExternal` (Registry and Tx paths) before any allocation; update the doc comment that made this a caller obligation +- [x] 1.3 Registry tests: gated refusal creates nothing (no claim/token), typed errors surface, nil gate admits + +## 2. Core wiring + +- [x] 2.1 `NewExternalClaimGate(entQ, connectTarget)` in `internal/server`; inject into the member-facing registry constructions in `server.go` +- [x] 2.2 member_domains: replace `externalClaimsGranted` + local const with the shared gate; map typed errors to the existing plan-gate / connect-target copy +- [x] 2.3 Wiring tests: the member domains handler constructor always carries a gate (in-package test); the server.go/fedwiki.go registry injections are asserted end-to-end by live verification rather than introspection + +## 3. FedWiki switchover + +- [x] 3.1 fedwiki web config gains `CustomDomainsGate domains.ExternalClaimGate`; wire it where fedwiki web is constructed; inject the gate into fedwiki-built registries serving member claim starts +- [x] 3.2 Replace `customDomainsEnabled` + local const at all four call sites (`api.go`, `partials.go` ×3, `customdomain.go` glue); delete the helper +- [x] 3.3 Update fedwiki tests to the injected gate + +## 4. Gate and docs + +- [x] 4.1 Full gate: gofmt, `go build ./...`, `go vet ./...`, `go test ./...` +- [x] 4.2 Close the debt entry in `status/issues.md` (resolved, pointing at this change) diff --git a/openspec/specs/domains-registry/spec.md b/openspec/specs/domains-registry/spec.md index 0f8315f..dd21fa0 100644 --- a/openspec/specs/domains-registry/spec.md +++ b/openspec/specs/domains-registry/spec.md @@ -204,15 +204,19 @@ Creating an `external` claim SHALL be permitted only when (a) the workspace's pr active pool holds a `boolean_entitlements` row for `resource_key = 'external_domain_claims'` with `granted = true`, and (b) a connect target is configured for serving. An absent `boolean_entitlements` row SHALL count as not -granted. Surfaces offering external-domain entry (the FedWiki create form) SHALL -render the affordance only when both conditions hold, and a denied request SHALL -receive an error naming the plan gate. The `external_domain_claims` resource key -SHALL be seeded by the domains migration stream (kind `boolean`, provider NULL, name -unprefixed per the provider-registry platform-key convention). Entitlement data -previously attached to `fedwiki_custom_domains` — including -`core.boolean_entitlements`, `core.entitlement_set_rules`, and any -`discourse.group_mappings` rows — SHALL be migrated to the new key and the old key -removed. +granted. The gate SHALL be enforced in the registry's claim-initiation path itself, +via a gate injected at registry construction: every entry point that starts an +external claim inherits the check from the allocation API and SHALL NOT need to +reimplement it. Surfaces offering external-domain entry (the FedWiki create form) +SHALL render the affordance only when the same gate admits, and a denied request +SHALL receive an error naming the failed condition (the plan gate, or the missing +connect target) derived from the gate's typed refusal. The +`external_domain_claims` resource key SHALL be seeded by the domains migration +stream (kind `boolean`, provider NULL, name unprefixed per the provider-registry +platform-key convention). Entitlement data previously attached to +`fedwiki_custom_domains` — including `core.boolean_entitlements`, +`core.entitlement_set_rules`, and any `discourse.group_mappings` rows — SHALL be +migrated to the new key and the old key removed. #### Scenario: Unentitled workspace cannot start verification @@ -220,6 +224,20 @@ removed. - **THEN** the request SHALL be refused server-side regardless of UI state - **AND** the error SHALL name the plan gate +#### Scenario: The registry itself refuses, not just the surfaces + +- **WHEN** an external-claim initiation reaches the registry's allocation API from + any entry point while the workspace lacks the granted boolean +- **THEN** the registry SHALL refuse before allocating anything +- **AND** no pending claim, token, or verification workflow SHALL be created + +#### Scenario: A new entry point inherits the gate + +- **WHEN** a new consumer starts external claims through the registry's + claim-initiation API without adding any gate code of its own +- **THEN** the entitlement and connect-target conditions SHALL still be enforced + for its requests + #### Scenario: Entitled workspace on an unconfigured deployment sees no affordance - **WHEN** a workspace holds the granted boolean but no connect target is configured diff --git a/status/issues.md b/status/issues.md index 1e39f61..37c8d74 100644 --- a/status/issues.md +++ b/status/issues.md @@ -548,22 +548,39 @@ Labels: `bug`, `testing` The reconcile suite (`internal/integrations/discourse/workflows/reconcile_test.go`) is tx-rollback against a fake Discourse server, but the shared stack DB's *committed* rows are visible inside the transaction. The 2026-07-21 harness fix (tx-private `testflag_*` resource key per harness) removed the biggest leak — desired-set members from live-session `user_links` — but `TestReconcilePersonTargeted` iterates **all** group mappings, so any committed mapping (e.g. an operator configuring one on the live stack, or a failed `TestDiscourseMappingWalkthrough` run leaving its `walkthrough-members` row) makes the fake 404 and the test fail. Currently the stack has no committed mappings; if this test starts failing, check `discourse.group_mappings` for residue first. Real fix: scope person-targeted reconciliation testing to harness-created mappings, or run reconcile tests against a dedicated database. -### Custom-domains entitlement gate lives in fedwiki's web layer, not core +### ~~Custom-domains entitlement gate lives in fedwiki's web layer, not core~~ (RESOLVED 2026-07-26) -Labels: `debt`, `architecture` +Labels: `debt`, `architecture`, `resolved` -The `external_domain_claims` boolean gate is enforced in -`internal/integrations/fedwiki/web/customdomain.go` (`customDomainsEnabled`), -though the resource key is platform-owned (seeded by the domains migration -stream, provider NULL) and the claim model is core's. Works today because -FedWiki hosts the only external-claim entry point; it becomes a coupling wart -the moment a second integration consumes domains or the domains system is -extracted (see `integration-interdependence-exploration.md`, which names this -the one prophylactic worth doing regardless of path). Fix direction: move the -gate into core — either into `internal/domains` claim initiation or the -shared claim-start glue in `internal/server` — so entry points inherit it -rather than reimplement it. Noticed during the 2026-07-26 domains -de-centering explore; not blocking anything current. +**Resolved by `centralize-external-claim-gate`** (same-day): enforcement +moved into `Registry.ClaimExternal` via an injected +`domains.ExternalClaimGate` (pre-lock, so neither of the original +objections — entitlement-table reads in the domains package, a second lock +order — applies). One constructor (`internal/server.NewExternalClaimGate`) +feeds both member-facing registries and both surfaces' affordance checks; +the duplicated helpers and resource-key constants in fedwiki web and +member_domains are gone. Original concern, for the record: the +`external_domain_claims` gate was enforced only in per-surface web code, so +any second consumer of domains — or extraction — would have shipped +ungated. + +### Operator domains: force-release renders for placed claims the server will refuse + +Labels: `bug`, `frontend`, `testing` + +The operator Domains list renders a force-release button on every live +non-root claim, but `ForceReleaseClaim` enforces the placement guard — so +clicking it on a claim with placements refuses server-side and no success +toast appears. A dead-end affordance (audit finding #5 family), discovered +2026-07-26 when a placed member claim existed in the stack DB for the first +time: `TestOperatorDomainsWalkthrough` assumes the first force-release +button succeeds and times out waiting for the toast. Two fixes wanted: +(1) don't render (or disable-with-title) force-release for placed claims, +matching the member surface's release guard UX; (2) make the walkthrough +pick an unplaced claim or treat the guard refusal as a pass-worthy branch. +Until then the walkthrough is reliable only against a stack whose live +claims are unplaced (its historical state). Not caused by — only exposed +during — the gate-centralization work. ## Design feedback (upstream design repo)