Files
cgalo5758 782ca8f326 Derive Stripe mode from API key and refine disabled controls
Derive Stripe test/live mode from the API key prefix at boot, failing on
unrecognized prefixes, and drop the separate `stripe-mode` config key.

Refine disabled controls to render through the shared `disabledControl`
part with the not-allowed cursor, and add a lint rule refusing
hand-rolled disabled buttons.

Adjust plan cards to offer no purchase control on free rungs, fix bound
checkbox Bool handling, and rename "Public/Private" to "Listed/Unlisted"
with enhanced readiness verdicts.
2026-09-13 16:59:12 -05:00

1344 lines
54 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"context"
"database/sql"
"errors"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"time"
"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/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
siteusage "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/usage"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
coreweb "git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/google/uuid"
"go.temporal.io/sdk/client"
)
// FedWikiPartialsHandler handles HTMX partial requests for FedWiki management.
type FedWikiPartialsHandler struct {
Include server.Include
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
// Registry is the domains registry: the authority for every name FedWiki
// serves. The external-domain flow reads and writes claims through it
// (design D4/D8). Nil in render tests, which touch no claim path.
Registry *domains.Registry
Database *sql.DB
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
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
// 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.
type FedWikiPartialsConfig struct {
SiteQ fwmod.Querier
EntitlementsQ entitlements.Querier
Registry *domains.Registry
Database *sql.DB
Logger *slog.Logger
TemporalClient client.Client
AuthConfig *auth.Config
FedWikiAllowedDomains []string
FedWikiSiteScheme string
CustomDomainTarget string
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
SwapCooldown time.Duration
// TemplatesFS is FedWiki's own template directory (fedwiki_*.html),
// supplied by the Adapter from its embedded templates tree — see
// internal/integrations/fedwiki.Adapter.Templates (the UIProvider hook)
// and design.md Decision 8. This package cannot embed the tree itself:
// the templates live at the integration root (a sibling of this
// package, per design.md Decision 1's tree layout), and importing the
// root package from here to reach an embed.FS would cycle back through
// RegisterRoutes, which constructs these handlers.
TemplatesFS fs.FS
// Include composes core's domains surface into the sites card on the
// server (page-anatomy "A page arrives complete"); supplied by core
// through server.Deps.
Include server.Include
}
// NewFedWikiPartialsHandler creates a new FedWikiPartialsHandler.
func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandler, error) {
// Parse only FedWiki's own member-facing partials by explicit name — not
// a "*.html" glob — so this template set never depends on function names
// (renderBody, fieldErr, stripeEntityURL) that other integrations' or
// core's operator-side templates need. Only routeURL is required: two of
// these four templates link back into their own HTMX routes with it.
// fedwiki_delete_confirm.html and fedwiki_delete_success.html are gone
// (finding FA-41): permanent delete runs through the shared confirmAction
// modal like every other site action, so it never needed a template of
// its own for either the confirm step or the result.
tmpl, err := template.New("fedwiki-partials").Funcs(template.FuncMap{
"routeURL": coreweb.RouteURL,
}).ParseFS(cfg.TemplatesFS,
"fedwiki_sites.html",
"fedwiki_create_form.html",
"fedwiki_create_success.html",
"fedwiki_custom_domain_pending.html",
)
if err != nil {
return nil, err
}
// The shared page-anatomy parts (statusBadge, emptyState) render the
// badges and empty states these member partials use (sweep group 10).
if tmpl, err = coreweb.ParseUIPartials(tmpl); err != nil {
return nil, err
}
return &FedWikiPartialsHandler{
Include: cfg.Include,
SiteQ: cfg.SiteQ,
EntitlementsQ: cfg.EntitlementsQ,
Registry: cfg.Registry,
Database: cfg.Database,
Logger: cfg.Logger,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
CustomDomainTarget: cfg.CustomDomainTarget,
CustomDomainsGate: cfg.CustomDomainsGate,
SupportURL: cfg.SupportURL,
SwapCooldown: cfg.SwapCooldown,
Templates: server.NewSafeTemplates(tmpl, cfg.Logger),
}, nil
}
// RegisterRoutes registers all HTMX partial routes.
func (h *FedWikiPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/fedwiki/sites", h.GetSites)
mux.HandleFunc("GET /partials/fedwiki/create-form", h.GetCreateForm)
mux.HandleFunc("GET /partials/fedwiki/custom-domains/{claimID}", h.GetCustomDomainStatus)
mux.HandleFunc("POST /partials/fedwiki/custom-domains/{claimID}/check", h.CheckCustomDomainNow)
mux.HandleFunc("POST /partials/fedwiki/custom-domains/{claimID}/cancel", h.CancelCustomDomainVerification)
mux.HandleFunc("POST /partials/fedwiki/sites", h.CreateSite)
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/archive", h.ArchiveSite)
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/restore", h.RestoreSite)
mux.HandleFunc("POST /partials/fedwiki/sites/{domain}/keep-active", h.KeepSiteActive)
mux.HandleFunc("DELETE /partials/fedwiki/sites/{domain}", h.DeleteSite)
}
// SiteViewModel represents a site for template rendering.
type SiteViewModel struct {
ID string
Domain string // Full domain as stored in database
IsCustomDomain bool
CreatedAt string
URL string
Status string // active | readonly | archived
IsReadonly bool
IsArchived bool
IsForceReduced bool // parked by a downgrade/swap rotation, not archived by choice — Keep active/Restore on this site is cooldown-gated
}
// SitesData holds data for the sites list partial.
type SitesData struct {
Sites []SiteViewModel // active + read-only (the live list)
ArchivedSites []SiteViewModel // archived (shown in a separate section)
HasReadonly bool // any site parked read-only by a downgrade
CurrentCount int64
Quota int64
CanCreate bool
HasEntitlement bool
// EntitlementCheckFailed marks a transient quota-lookup failure (audit
// #24 interim guard): the member's sites must stay listed with an
// explicit try-again notice, never masquerade as "no access granted".
EntitlementCheckFailed bool
// NoDomainsConfigured mirrors the create-form's zero-domains guard
// (task 4.6): true when the deployment has no FedWikiAllowedDomains at
// all, so no member can create a hosted site regardless of their plan.
// Unlike HasEntitlement this is never the member's fault, and the blocked
// empty state below names it separately (ux-first-run task 3.6) so a
// member is never told to check their plan when the real blocker is a
// deployment that hasn't configured any site domains yet.
NoDomainsConfigured bool
// NoPublishedPlan is the entitlement branch's third case (fedwiki-sites
// spec "The sites card distinguishes an empty catalog from an excluded
// entitlement", ACC-7): true when no published product's entitlement
// set confers the fedwiki_sites resource key at all, so the empty
// state says the deployment hasn't published a website plan yet
// instead of blaming the member's own plan. Only meaningful (and only
// computed) when HasEntitlement is false.
NoPublishedPlan bool
// CooldownMsg, when non-empty, is the remaining-time message for the
// workspace's active-site swap cooldown. Read-only display: the buttons
// gated by it (readonly sites' Keep active, force-reduced archived sites'
// Restore) are always the same rotation the server enforces on click —
// see checkSwapCooldown and RestoreSite/KeepSiteActive's `rotation` gates.
CooldownMsg string
// DomainsSurface is core's member domains surface, composed on the
// server (page-anatomy "A page arrives complete").
DomainsSurface template.HTML
// CustomDomainClaims lists this workspace's VERIFIED external domain
// claims that have no site yet — verification no longer creates the site
// itself (design D4), so the verified state needs its one-click create
// somewhere. Pending claims are deliberately absent
// (dissolve-member-domains): they already surface via the dashboard's
// pending notice and the embedded Domains section below the sites list.
CustomDomainClaims []CustomDomainClaimViewModel
Error string
}
// CustomDomainClaimViewModel is one verified external domain claim without a
// site — the banner's single remaining job is its one-click create.
type CustomDomainClaimViewModel struct {
ClaimID string
Domain string
CreatedAt string
}
// CreateControl builds the shared disabledControl part's data (design D6,
// spec form-conventions "Disabled controls carry their reason in the DOM")
// for the Create site button when CanCreate is false: the member surface's
// visible-reason variant (AsVisible), the same idiom member_products.go
// uses. The reason names the limit when the workspace holds an entitlement
// at or over it; otherwise the allowance itself could not be read.
func (d SitesData) CreateControl() server.DisabledControl {
reason := "Site creation is unavailable right now"
if d.HasEntitlement {
reason = "Site limit reached"
}
return server.NewDisabledControl(
"fedwiki-create-site-reason",
"Create site",
"btn btn-outline-primary btn-sm",
reason,
).AsVisible()
}
// KeepActiveControl builds the shared disabledControl part's data for a
// read-only site's Keep active button during the swap cooldown (design D6):
// the tooltip-on-wrapper variant, since the row it sits in has no room to
// stack a visible reason beside each action. The id is unique per site so
// several rows on the same page never collide.
func (s SiteViewModel) KeepActiveControl(reason string) server.DisabledControl {
return server.NewDisabledControl(
"fedwiki-site-keep-active-reason-"+s.ID,
"Keep active",
"btn btn-outline-primary btn-sm me-1",
reason,
)
}
// RestoreControl builds the shared disabledControl part's data for a
// force-reduced archived site's Restore button during the swap cooldown
// (design D6), the same tooltip variant KeepActiveControl uses and for the
// same reason.
func (s SiteViewModel) RestoreControl(reason string) server.DisabledControl {
return server.NewDisabledControl(
"fedwiki-site-restore-reason-"+s.ID,
"Restore",
"btn btn-outline-primary btn-sm me-1",
reason,
)
}
// GetSites handles GET /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) GetSites(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderError(w, "fedwiki_sites.html", "Unauthorized")
return
}
workspaceID := session.WorkspaceID
// Get sites by workspace
sites, err := h.SiteQ.ListSitesByWorkspace(r.Context(), workspaceID)
if err != nil {
h.Logger.Error("failed to get user sites", slog.Any("error", err))
h.renderError(w, "fedwiki_sites.html", "Failed to retrieve sites")
return
}
// Build view models, splitting the live list (active + read-only) from the
// Archived section. Archived sites are hidden from the active list.
var siteVMs, archivedVMs []SiteViewModel
hasReadonly := false
for _, site := range sites {
vm := SiteViewModel{
ID: site.SiteID,
Domain: site.Domain,
IsCustomDomain: site.IsCustomDomain,
CreatedAt: site.CreatedAt.Format("Jan 2, 2006"),
URL: h.buildSiteURL(site.Domain, site.IsCustomDomain),
Status: site.Status,
IsReadonly: site.Status == "readonly",
IsArchived: site.Status == "archived",
IsForceReduced: site.ForceReducedAt.Valid,
}
switch site.Status {
case "archived":
archivedVMs = append(archivedVMs, vm)
default:
if vm.IsReadonly {
hasReadonly = true
}
siteVMs = append(siteVMs, vm)
}
}
// Get quota from entitlements
count := int64(len(siteVMs))
var quota int64
canCreate := false
hasEntitlement := false
q, err := h.getWorkspaceQuota(r.Context(), workspaceID)
entitlementCheckFailed := false
if err == nil {
hasEntitlement = true
quota = q.resourceLimit
canCreate = q.currentUsage < q.resourceLimit
count = q.currentUsage
} else if !errors.Is(err, sql.ErrNoRows) {
// Only a no-rows result means "no entitlement"; anything else is a
// transient lookup failure and must not hide the member's live sites
// behind the no-access empty state (audit #24 interim guard).
h.Logger.Error("failed to load workspace quota", slog.Any("error", err))
entitlementCheckFailed = true
}
// Read-only cooldown check for the Keep active/Restore hints below — the
// authoritative gate is still re-checked server-side on click.
cooldownMsg := h.checkSwapCooldown(r.Context(), workspaceID)
// The "no plan includes this yet" branch only matters when the member
// has no entitlement to begin with; skip the catalog query otherwise.
noPublishedPlan := false
if !hasEntitlement {
noPublishedPlan = !h.anyPublishedProductConfersResourceKey(r.Context(), "fedwiki_sites")
}
data := SitesData{
Sites: siteVMs,
ArchivedSites: archivedVMs,
HasReadonly: hasReadonly,
CurrentCount: count,
Quota: quota,
CanCreate: canCreate,
HasEntitlement: hasEntitlement,
EntitlementCheckFailed: entitlementCheckFailed,
NoDomainsConfigured: len(h.FedWikiAllowedDomains) == 0,
NoPublishedPlan: noPublishedPlan,
CooldownMsg: cooldownMsg,
CustomDomainClaims: h.customDomainClaims(r.Context(), workspaceID),
DomainsSurface: h.domainsSurface(r),
}
h.Templates.Render(w, "fedwiki_sites.html", data)
}
// anyPublishedProductConfersResourceKey reports whether the catalog has at
// least one published product whose entitlement set confers resourceKey —
// the site card's third empty-state branch needs to tell "this deployment
// hasn't published a plan for this yet" apart from "your plan excludes
// this" (fedwiki-sites spec "The sites card distinguishes an empty catalog
// from an excluded entitlement", ACC-7). A query failure fails toward the
// entitlement-branch copy (true): a transient DB hiccup must never render
// the "no plans exist" claim when plans might well exist.
func (h *FedWikiPartialsHandler) anyPublishedProductConfersResourceKey(ctx context.Context, resourceKey string) bool {
var exists bool
err := h.Database.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1
FROM core.products p
JOIN core.entitlement_set_rules r ON r.set_id = p.entitlement_set_id
WHERE p.lifecycle_status = 'published'
AND r.is_active = TRUE
AND r.resource_key = $1
)`, resourceKey).Scan(&exists)
if err != nil {
h.Logger.Warn("failed to check published plan for resource key", slog.String("resource_key", resourceKey), slog.Any("error", err))
return true
}
return exists
}
// domainsSurface composes core's member domains surface into the card on
// the server; a failure renders an inline notice, never a blank region.
func (h *FedWikiPartialsHandler) domainsSurface(r *http.Request) template.HTML {
if h.Include == nil {
return ""
}
out, err := h.Include(r, "/partials/domains/claims")
if err != nil {
h.Logger.Error("domains surface failed to compose", slog.Any("error", err))
return template.HTML(`<div class="alert alert-danger" role="alert">Could not load domains.</div>`)
}
return out
}
// customDomainClaims builds the sites-list banner rows: the workspace's
// VERIFIED external claims with no placement yet — ready for the one-click
// create. (Pending claims left the banner in dissolve-member-domains; the
// dashboard notice and the embedded Domains section carry them now.)
//
// Carved `member` claims are excluded: they exist only to hold a hosted
// site's name and auto-release when emptied, so a zero-placement one is a
// transient saga state, not something to show a member. Best-effort — the
// sites list must render even when the registry read fails.
func (h *FedWikiPartialsHandler) customDomainClaims(ctx context.Context, workspaceID string) []CustomDomainClaimViewModel {
if h.Registry == nil {
return nil
}
claims, err := h.Registry.ListLiveClaims(ctx, workspaceID)
if err != nil {
h.Logger.Error("failed to list domain claims", slog.Any("error", err))
return nil
}
var out []CustomDomainClaimViewModel
for _, claim := range claims {
if claim.Kind != domains.KindExternal {
continue
}
// Verified-and-empty only (dissolve-member-domains): pending claims
// surface via the dashboard notice and the embedded Domains section,
// so the banner keeps the one job nothing else can host — the
// one-click create that verification no longer performs itself (D4).
if claim.Status != domains.StatusActive || claim.PlacementCount > 0 {
continue
}
out = append(out, CustomDomainClaimViewModel{
ClaimID: claim.ClaimID,
Domain: claim.RootFqdn,
CreatedAt: claim.CreatedAt.Format("Jan 2, 2006"),
})
}
return out
}
// hasLiveExternalClaim reports whether the workspace already holds a live
// external claim — pending or verified, placed or not. It exists only to keep
// the create form's external-domain affordance visible for a workspace whose
// entitlement is gone but whose claim is not: creation under an owned claim
// is instant and ungated, so the input has real work left to do. Best-effort;
// a registry read failure falls back to the entitlement's answer.
func (h *FedWikiPartialsHandler) hasLiveExternalClaim(ctx context.Context, workspaceID string) bool {
if h.Registry == nil {
return false
}
claims, err := h.Registry.ListLiveClaims(ctx, workspaceID)
if err != nil {
h.Logger.Error("failed to list domain claims", slog.Any("error", err))
return false
}
for _, claim := range claims {
if claim.Kind == domains.KindExternal {
return true
}
}
return false
}
// CreateFormData holds data for the create form partial.
type CreateFormData struct {
AllowedDomains []string // All domains where users can create sites, guards the zero-domains branch
Form forms.FormView
}
// resolveCreateFormAffordances computes the create form's runtime state,
// which both an unbound render (GetCreateForm) and a refused resubmission
// (renderCreateFormRefusal) need alike: whether the "use a domain I own"
// affordance is open, and the commit's disabled state when the workspace
// is at its site quota (finding FA-17, through the shared disabledControl
// part rather than a bare `disabled` and an unrelated alert).
func (h *FedWikiPartialsHandler) resolveCreateFormAffordances(ctx context.Context, workspaceID string) (customDomainsOpen bool, commitState *forms.DisabledControlView) {
var count, quota int64
canCreate := false
q, err := h.getWorkspaceQuota(ctx, workspaceID)
if err == nil {
quota = q.resourceLimit
count = q.currentUsage
canCreate = count < quota
}
if !canCreate {
commitState = &forms.DisabledControlView{
ID: "fedwiki-site-create-commit-reason",
Label: "Create site",
Classes: "btn btn-primary",
Reason: fmt.Sprintf("You have reached your site limit (%d of %d sites).", count, quota),
// Member surface density (anatomy_disabled.go's AsVisible):
// the reason renders as ordinary text, not a tooltip.
Visible: true,
}
}
cd, cdErr := customDomainsEnabled(ctx, h.CustomDomainsGate, workspaceID)
if cdErr != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr))
}
// A workspace that already holds a live external claim keeps the
// affordance whatever the entitlement says: the gate covers NEW claims
// only, and names under a claim it already owns still place instantly.
// Hiding the input would strand the claim with no way to use it. The
// server-side gate in CreateSite stays authoritative either way.
if !cd {
cd = h.hasLiveExternalClaim(ctx, workspaceID)
}
return cd, commitState
}
// GetCreateForm handles GET /partials/fedwiki/create-form
func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
workspaceID := session.WorkspaceID
customDomains, commitState := h.resolveCreateFormAffordances(r.Context(), workspaceID)
// customDomainsEnabled is the hidden field isCustomDomain's ShowIf
// reads (design D2); it carries no authority of its own (finding
// FA-10) and is set fresh on every render, never read back from a
// submission.
values := forms.NewValues()
enabledRaw := ""
if customDomains {
enabledRaw = "true"
}
values.Set("customDomainsEnabled", enabledRaw)
view := forms.Render(siteCreateForm, forms.Binding{
Mode: forms.ModeUnbound,
Values: values,
Options: siteCreateOptions(h.FedWikiAllowedDomains),
})
view.CommitState = commitState
h.Templates.Render(w, "fedwiki_create_form.html", CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
Form: view,
})
}
// CreateSite handles POST /partials/fedwiki/sites
func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
values, errs := siteCreateForm.ParseWith(r, siteCreateOptions(h.FedWikiAllowedDomains))
if errs.Any() {
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// 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 row and the farm call disagree.
domain := dnsname.Normalize(values.String("domain"))
// selectedDomain is "" for a custom-domain submission: the field is not
// shown while isCustomDomain is checked (forms.go's field-order
// comment), and the workflow input documents SiteDomain as ignored for
// custom domains, so an empty value there is correct, not missing data.
selectedDomain := values.String("selectedDomain")
isCustomDomain := values.Bool("isCustomDomain")
// External-domain branch: the registry's four-branch disposition (design
// D4). Only the unclaimed branch starts a verification; a name already
// inside the workspace's active claim falls through to the ordinary
// create path below and is created instantly.
//
// Classification comes FIRST, and the entitlement gates the unclaimed
// branch alone: the boolean buys the right to claim a NEW external
// domain, not the right to use one already claimed. A name under a claim
// the workspace already holds places instantly whatever the plan says
// ("Nested site under an owned claim needs no verification"), so a
// revoked entitlement must not strand a live claim.
if isCustomDomain {
disposition, userMsg, err := classifyCustomDomain(r.Context(), h.Registry, session.WorkspaceID, domain)
if err != nil {
h.Logger.Error("domain availability check failed", slog.Any("error", err))
errs.Form("We couldn't check that domain right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
switch disposition {
case dispositionRefused, dispositionOwnPending:
// userMsg is about the typed domain itself, so it lands under
// that field rather than in the form-level slot (finding
// FA-27, carrying FA-8's field-attribution rule to this form).
errs.Field("domain", userMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
case dispositionUnclaimed:
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
if err != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
errs.Form("We couldn't check your plan right now. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
if !enabled {
errs.Form("Your current plan doesn't include custom domains.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
initiation, msg := startClaimVerification(r.Context(), h.Registry, h.TemporalClient, h.Logger,
session.WorkspaceID, domain, h.CustomDomainTarget)
if msg != "" {
errs.Form(msg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
h.Templates.Render(w, "fedwiki_custom_domain_pending.html", CustomDomainPendingData{
ClaimID: initiation.ClaimID,
Domain: initiation.Domain,
Records: newCustomDomainRecords(initiation.Domain,
initiation.TXTRecordName, initiation.TXTRecordValue, initiation.ConnectTarget),
StatusText: "Waiting for your DNS records…",
// With the time of day, matching the status view: the
// verification window is hours, not days.
ExpiresAt: initiation.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
Polling: true,
})
// The banner gains the pending claim.
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
return
}
// dispositionOwnActive falls through: the claim already proves control.
} else {
// Hosted path only: a custom domain is a full FQDN, not a label
// under one of the farm domains. selectedDomain is already one of
// h.FedWikiAllowedDomains here: Parse refused anything else against
// siteCreateOptions above, so the old manual membership check is
// gone.
// Validate domain is a legal DNS label (lowercase enforced by
// dnsname.Normalize above).
if errMsg := dnsname.ValidateDNSLabel(domain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Defense-in-depth: check assembled FQDN length (RFC 1035 §2.3.4)
if errMsg := dnsname.ValidateFQDN(domain, selectedDomain); errMsg != "" {
errs.Field("domain", errMsg)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
errs.Form("Site creation service is not available")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Start the workflow
workflowID := "create-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := workflows.CreateFedWikiSiteWorkflowInput{
WorkspaceID: session.WorkspaceID,
Domain: domain,
SiteDomain: selectedDomain,
OwnerName: session.Username,
OwnerID: session.OIDCSubject,
IsCustomDomain: isCustomDomain,
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.CreateFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow", slog.Any("error", err))
errs.Form("Failed to initiate site creation")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
h.Logger.Info("site creation workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain),
slog.String("siteDomain", selectedDomain))
// Wait for the workflow to complete — but only within the wait budget, so
// the still-working answer can land before the server's write deadline.
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.CreateFedWikiSiteWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("site creation outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.Logger.Error("workflow execution failed", slog.Any("error", err))
errs.Form("Site creation failed. Try again.")
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site creation workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
errs.Form(result.ErrorMessage)
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs)
return
}
// Render success partial with HX-Trigger to refresh sites list
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
data := struct{ Domain string }{Domain: result.Domain}
h.Templates.Render(w, "fedwiki_create_success.html", data)
}
// DeleteSite handles DELETE /partials/fedwiki/sites/{domain}: permanent
// delete, triggered through the shared confirmAction modal like every
// other site-status action in this file (finding FA-41: the delete-confirm
// modal this once opened, and its GET route, were dead weight, superseded
// by that shared trigger and never actually reachable through it; its
// hx-target named a node, #deleteSiteModalBody, no live button pointed
// at). Errors and success both answer through siteActionResult, exactly as
// ArchiveSite, RestoreSite and KeepSiteActive already do, into the
// "Delete permanently" trigger's own data-action-target: the modal-shaped
// success and error partials this replaced were swapping into that same
// small inline target and had never fit it.
func (h *FedWikiPartialsHandler) DeleteSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.Logger.Error("failed to get session")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
domain := r.PathValue("domain")
if domain == "" {
h.siteActionResult(w, "Domain is required")
return
}
// Verify site belongs to user's workspace
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
h.Logger.Error("failed to get site", slog.Any("error", err))
h.siteActionResult(w, "Site not found")
return
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
// as the failed-lookup branch above, and keep the distinction in
// the log only.
h.Logger.Debug("site delete refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
// Check Temporal client
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
h.siteActionResult(w, "Site creation service is not available")
return
}
// Start the workflow
workflowID := "delete-fedwiki-site-" + uuid.New().String()
workflowOptions := client.StartWorkflowOptions{
ID: workflowID,
TaskQueue: queues.Main,
}
input := workflows.DeleteFedWikiSiteWorkflowInput{
Domain: domain,
WorkspaceID: session.WorkspaceID,
WasActive: site.Status == "active",
SupportURL: h.SupportURL,
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(), workflowOptions, workflows.DeleteFedWikiSiteWorkflow, input)
if err != nil {
h.Logger.Error("failed to start workflow", slog.Any("error", err))
h.siteActionResult(w, "Failed to initiate site deletion")
return
}
h.Logger.Info("site deletion workflow started",
slog.String("workflowID", we.GetID()),
slog.String("domain", domain))
// Wait for the workflow to complete — bounded like CreateSite's wait.
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.DeleteFedWikiSiteWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("site deletion outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.Logger.Error("workflow execution failed", slog.Any("error", err))
h.siteActionResult(w, "Site deletion failed. Try again.")
return
}
// Check if the workflow reported failure
if !result.Success {
h.Logger.Warn("site deletion workflow returned failure",
slog.String("domain", domain),
slog.String("error", result.ErrorMessage))
h.siteActionResult(w, result.ErrorMessage)
return
}
// Success: HX-Trigger refreshes the sites list, which is the
// confirmation; the deleted row is simply gone from it.
h.siteActionResult(w, "")
}
// changeSiteStatus verifies ownership and runs SetSiteStatusWorkflow to move the
// caller's site to target (active|readonly|archived). Returns a user-facing error
// message ("" on success) plus pending=true when the request stopped waiting but
// the workflow is still running (see workflowWaitInterrupted).
//
// Every member status transition in this file — ArchiveSite, RestoreSite, and
// KeepSiteActive's below-the-limit branch — funnels through here, and the
// swap branch funnels through SwapActiveSiteWorkflow. That is why no handler
// in this package touches the domains registry directly: the placement's
// `servable` flag is written where the status write actually lands, in
// workflows.SetSiteStatusActivity (archived ⇒ false, active/readonly ⇒ true),
// and the swap path crosses no servable boundary at all. DeleteSite's permanent
// delete likewise releases the placement inside DeleteFedWikiSiteActivity.
// Keeping the registry write beside the status write means a transition can
// never land locally while the name's servability silently disagrees.
func (h *FedWikiPartialsHandler) changeSiteStatus(r *http.Request, domain, target string) (errMsg string, pending bool) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
return "Unauthorized", false
}
if domain == "" {
return "Domain is required", false
}
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err != nil {
return "Site not found", false
}
if site.WorkspaceID != session.WorkspaceID {
// A distinct answer here is an existence oracle for other tenants'
// domains (security-audit-remediation-2 design D5): answer exactly
// as the failed-lookup branch above, and keep the distinction in
// the log only. RestoreSite and KeepSiteActive already gate
// ownership before calling in, so this branch is reached through
// ArchiveSite.
h.Logger.Debug("site status change refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("target_status", target),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
return "Site not found", false
}
if h.TemporalClient == nil {
h.Logger.Error("Temporal client not configured")
return "Site creation service is not available", false
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
client.StartWorkflowOptions{ID: "set-fedwiki-status-" + uuid.New().String(), TaskQueue: queues.Main},
workflows.SetSiteStatusWorkflow,
workflows.SetSiteStatusWorkflowInput{
Domain: domain,
WorkspaceID: session.WorkspaceID,
CurrentStatus: site.Status,
TargetStatus: target,
SupportURL: h.SupportURL,
})
if err != nil {
h.Logger.Error("failed to start status workflow", slog.Any("error", err))
return "Failed to update the site. Try again.", false
}
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.SetSiteStatusWorkflowOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("status change outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
return "", true
}
h.Logger.Error("status workflow execution failed", slog.Any("error", err))
return "Failed to update the site. Try again.", false
}
if !result.Success {
return result.ErrorMessage, false
}
return "", false
}
// workflowWaitBudget caps how long a partial handler blocks on a Temporal
// workflow before answering "still working" (audit #19). It must sit well
// inside http.Server's 8s WriteTimeout: a pending response written after that
// network deadline never reaches the browser.
const workflowWaitBudget = 6 * time.Second
// workflowWaitInterrupted reports whether an error from WorkflowRun.Get means
// this request stopped waiting (wait budget spent or client disconnect)
// rather than the workflow failing — the workflow keeps running on its own.
func workflowWaitInterrupted(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}
// siteActionResult writes the outcome of an archive/restore action: on success an
// HX-Trigger refreshes the list; on failure an inline alert is returned to the
// action's target.
func (h *FedWikiPartialsHandler) siteActionResult(w http.ResponseWriter, errMsg string) {
if errMsg != "" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-danger py-2 small mb-2" role="alert">` + template.HTMLEscapeString(errMsg) + `</div>`))
return
}
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
}
// siteActionPending answers an action whose workflow outlived the request's
// wait window (audit #19 interim guard): honest still-working copy plus a
// list refresh instead of a false failure, pending a real pending-state
// lifecycle design.
func (h *FedWikiPartialsHandler) siteActionPending(w http.ResponseWriter) {
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<div class="alert alert-info py-2 small mb-2" role="status">Still working; this is taking longer than expected. The site list will update when it finishes; refresh in a minute if it hasn't.</div>`))
}
// ArchiveSite handles POST /partials/fedwiki/sites/{domain}/archive — moves the
// site to the archived state (recoverable; frees a quota slot).
func (h *FedWikiPartialsHandler) ArchiveSite(w http.ResponseWriter, r *http.Request) {
msg, pending := h.changeSiteStatus(r, r.PathValue("domain"), "archived")
if pending {
h.siteActionPending(w)
return
}
h.siteActionResult(w, msg)
}
// RestoreSite handles POST /partials/fedwiki/sites/{domain}/restore — returns an
// archived site to active. Restoring a site that is NOT force-reduced (your own
// active/archived site — your incumbent) is free. Restoring a force-reduced site
// (one a downgrade parked) brings a reserve back into the active set, which is a
// rotation gated by the swap cooldown. Quota-gated either way.
func (h *FedWikiPartialsHandler) RestoreSite(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.siteActionResult(w, "Unauthorized")
return
}
domain := r.PathValue("domain")
site, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err == nil && site.WorkspaceID != session.WorkspaceID {
// Same answer as the failed lookup below (design D5); the log
// keeps the distinction for operators.
h.Logger.Debug("site action refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", site.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
if err != nil {
h.siteActionResult(w, "Site not found")
return
}
// Identity gate: a force-reduced site is a downgrade-parked reserve — bringing
// it back is a rotation. A non-force-reduced site is the member's own
// incumbent and restores freely.
rotation := site.ForceReducedAt.Valid
if rotation {
// Claim the cooldown slot atomically (check-and-stamp under a
// per-workspace advisory lock) before running the workflow, so a
// second tab cannot also pass the check and double-rotate (audit #51).
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
h.siteActionResult(w, msg)
return
}
}
msg, pending := h.changeSiteStatus(r, domain, "active")
if pending {
// Outcome unknown — leave the force-reduced marker alone; a stale
// marker is corrected by the next reconcile/rotation.
h.siteActionPending(w)
return
}
if msg == "" && rotation {
// Cooldown already stamped by claimRotationSlot; the site is now the
// member's active choice, so drop the downgrade-parked marker.
h.clearForceReduced(r.Context(), domain)
}
h.siteActionResult(w, msg)
}
// KeepSiteActive handles POST /partials/fedwiki/sites/{domain}/keep-active —
// makes a read-only (force-reduced) site the active one. When the workspace is
// below its limit this is a plain reactivation; at the limit it is a swap (park
// the least-recently-modified active site, promote the chosen one) gated by the
// per-workspace swap cooldown.
func (h *FedWikiPartialsHandler) KeepSiteActive(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
domain := r.PathValue("domain")
chosen, err := h.SiteQ.GetSiteByDomain(r.Context(), domain)
if err == nil && chosen.WorkspaceID != session.WorkspaceID {
// Same answer as the failed lookup below (design D5); the log
// keeps the distinction for operators.
h.Logger.Debug("site action refused: not the caller's workspace",
slog.String("domain", domain),
slog.String("caller_workspace", session.WorkspaceID),
slog.String("owner_workspace", chosen.WorkspaceID))
h.siteActionResult(w, "Site not found")
return
}
if err != nil {
h.siteActionResult(w, "Site not found")
return
}
if chosen.Status == "active" {
h.siteActionResult(w, "") // already active — no-op
return
}
q, qErr := h.getWorkspaceQuota(r.Context(), session.WorkspaceID)
active, aErr := h.SiteQ.ListActiveSitesByWorkspace(r.Context(), session.WorkspaceID)
if aErr != nil {
h.siteActionResult(w, "Could not load your sites. Try again.")
return
}
belowLimit := qErr != nil || int64(len(active)) < q.resourceLimit
// A rotation brings a site into the active set in place of another: any
// activation at the limit (a swap that parks an active site), or reactivating
// a force-reduced (downgrade-parked) reserve. Reactivating your own incumbent
// below the limit is a free fill. Rotations are gated by the swap cooldown.
rotation := !belowLimit || chosen.ForceReducedAt.Valid
// Below the limit → plain reactivation. When it is a rotation, claim the
// cooldown slot atomically (check-and-stamp under the per-workspace advisory
// lock) first, so two tabs cannot both pass the check and both rotate.
if belowLimit {
if rotation {
if msg := h.claimRotationSlot(r.Context(), session.WorkspaceID); msg != "" {
h.siteActionResult(w, msg)
return
}
}
msg, pending := h.changeSiteStatus(r, domain, "active")
if pending {
h.siteActionPending(w)
return
}
if msg == "" && rotation {
h.clearForceReduced(r.Context(), domain)
}
h.siteActionResult(w, msg)
return
}
// At the limit → swap. Temporal is required; check it up front (read-only) so
// a provisioning outage does not burn the member's cooldown window.
if h.TemporalClient == nil {
h.siteActionResult(w, "Site creation service is not available")
return
}
// Select the park target and stamp the cooldown atomically under a
// per-workspace Postgres advisory lock, BEFORE starting the swap workflow
// (audit #51). Serializing the cooldown check, the park-target selection, and
// the cooldown stamp means two near-simultaneous tabs can no longer both pass
// the check, compute the same park target, and double-decrement usage — the
// second tab now observes the stamped cooldown and is refused. The long
// workflow wait runs after the lock is released so it is never held across
// multi-minute farm I/O.
var parkDomain, swapRefusal string
if err := h.withWorkspaceLock(r.Context(), session.WorkspaceID, func(ctx context.Context, q fwmod.Querier) error {
if msg := h.cooldownMsgFor(ctx, q, session.WorkspaceID); msg != "" {
swapRefusal = msg
return nil // still cooling down — commit is a no-op, nothing stamped
}
locked, err := q.ListActiveSitesByWorkspace(ctx, session.WorkspaceID)
if err != nil {
return err
}
if len(locked) == 0 {
swapRefusal = "Site creation service is not available"
return nil
}
parkDomain = locked[len(locked)-1].Domain // least-recently-modified active
return q.UpsertSiteSwapPolicy(ctx, session.WorkspaceID)
}); err != nil {
h.Logger.Error("failed to claim swap slot", slog.Any("error", err))
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
if swapRefusal != "" {
h.siteActionResult(w, swapRefusal)
return
}
we, err := h.TemporalClient.ExecuteWorkflow(r.Context(),
client.StartWorkflowOptions{ID: "swap-fedwiki-site-" + uuid.New().String(), TaskQueue: queues.Main},
workflows.SwapActiveSiteWorkflow,
workflows.SwapActiveSiteInput{
WorkspaceID: session.WorkspaceID,
ParkDomain: parkDomain,
ActivateDomain: domain,
SupportURL: h.SupportURL,
})
if err != nil {
h.Logger.Error("failed to start swap workflow", slog.Any("error", err))
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
waitCtx, cancelWait := context.WithTimeout(r.Context(), workflowWaitBudget)
defer cancelWait()
var result workflows.SwapActiveSiteOutput
if err := we.Get(waitCtx, &result); err != nil {
if workflowWaitInterrupted(err) {
h.Logger.Warn("swap outlived the request wait; workflow still running",
slog.String("workflowID", we.GetID()), slog.Any("error", err))
h.siteActionPending(w)
return
}
h.siteActionResult(w, "Failed to change the active site. Try again.")
return
}
if !result.Success {
msg := "Failed to change the active site. Try again."
if result.ErrorMessage != "" {
msg = result.ErrorMessage
}
h.siteActionResult(w, msg)
return
}
h.siteActionResult(w, "")
}
// clearForceReduced drops a site's downgrade-parked marker after the member has
// (re)activated it — it is now their active choice, not a reserve. Best-effort:
// a failure here only leaves a stale marker the next reconcile/rotation corrects,
// so it must not fail the user-facing action.
func (h *FedWikiPartialsHandler) clearForceReduced(ctx context.Context, domain string) {
if err := h.SiteQ.ClearForceReducedByDomain(ctx, domain); err != nil {
h.Logger.Warn("failed to clear force_reduced after rotation", slog.Any("error", err))
}
}
// withWorkspaceLock runs fn inside a transaction that holds a per-workspace
// Postgres advisory lock (pg_advisory_xact_lock, released on commit/rollback),
// serializing a workspace's active-site rotations so two tabs cannot interleave
// the cooldown check-and-stamp or the park-target selection (audit #51). fn
// receives a transaction-scoped querier; returning an error rolls the
// transaction back (nothing is stamped), otherwise it commits. This mirrors the
// advisory-lock idiom already used to serialize concurrent reconciles of one
// subscription in internal/fulfillment/reconcile.go — the established per-entity
// serialization pattern in this repo (Temporal workflow-ID conflict policies
// here guard only singleton daemon workflows, not per-request work).
func (h *FedWikiPartialsHandler) withWorkspaceLock(ctx context.Context, workspaceID string, fn func(context.Context, fwmod.Querier) error) error {
if h.Database == nil {
// No pooled DB to take an advisory lock on (e.g. unit tests) — run
// without cross-tab serialization rather than panicking on a nil handle.
return fn(ctx, h.SiteQ)
}
tx, err := h.Database.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// hashtext maps the workspace UUID text onto the bigint the advisory-lock API
// takes; an occasional hash collision only serializes two unrelated
// workspaces briefly, which is harmless.
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, workspaceID); err != nil {
return err
}
if err := fn(ctx, fwmod.New(tx)); err != nil {
return err
}
return tx.Commit()
}
// claimRotationSlot atomically checks the swap cooldown and, when the rotation is
// allowed, stamps it — both under the per-workspace advisory lock — so two
// concurrent tabs cannot both pass the check and both rotate the active slot
// (audit #51). It returns a user-facing refusal message if the workspace is
// still cooling down, or "" if the caller now owns the rotation for this window.
// The stamp lands BEFORE the caller runs its workflow: a later workflow failure
// burns the window, the accepted trade for never leaving the bypass open. On an
// infrastructure error it fails closed (refuses) so a lock or DB fault can never
// reopen that bypass.
func (h *FedWikiPartialsHandler) claimRotationSlot(ctx context.Context, workspaceID string) string {
if h.SwapCooldown <= 0 {
return "" // cooldown disabled → nothing to serialize or stamp
}
var refusal string
if err := h.withWorkspaceLock(ctx, workspaceID, func(ctx context.Context, q fwmod.Querier) error {
if msg := h.cooldownMsgFor(ctx, q, workspaceID); msg != "" {
refusal = msg
return nil // still cooling down — commit is a no-op, nothing stamped
}
return q.UpsertSiteSwapPolicy(ctx, workspaceID)
}); err != nil {
h.Logger.Error("failed to claim rotation slot", slog.Any("error", err))
return "We couldn't change which site is active. Try again later."
}
return refusal
}
// checkSwapCooldown returns a user-facing message if the workspace's active-site
// rotation is still within the configured cooldown window, or "" if it is
// allowed (no prior rotation, the window elapsed, or the cooldown is disabled).
// It reads through the handler's default querier; the swap path uses
// cooldownMsgFor to re-check under the transaction that holds the advisory lock.
func (h *FedWikiPartialsHandler) checkSwapCooldown(ctx context.Context, workspaceID string) string {
return h.cooldownMsgFor(ctx, h.SiteQ, workspaceID)
}
// cooldownMsgFor is checkSwapCooldown against an explicit querier, so the cooldown
// re-check can run through the same transaction that holds the per-workspace
// advisory lock — making the check-and-stamp atomic.
func (h *FedWikiPartialsHandler) cooldownMsgFor(ctx context.Context, q fwmod.Querier, workspaceID string) string {
window := h.SwapCooldown
if window <= 0 {
return "" // cooldown disabled
}
last, err := q.GetSiteSwapPolicy(ctx, workspaceID)
if err != nil {
return "" // no prior rotation → free
}
elapsed := time.Since(last)
if elapsed >= window {
return ""
}
windowDays := int(window.Hours()/24 + 0.5)
if windowDays < 1 {
windowDays = 1
}
remainingDays := int((window-elapsed).Hours()/24) + 1
return fmt.Sprintf("The active site changes once every %d day(s); try again in about %d day(s).", windowDays, remainingDays)
}
// Helper methods
func (h *FedWikiPartialsHandler) buildSiteURL(domain string, isCustomDomain bool) string {
scheme := h.FedWikiSiteScheme
if scheme == "" {
scheme = "https"
}
return scheme + "://" + domain
}
func (h *FedWikiPartialsHandler) renderError(w http.ResponseWriter, tmplName string, message string) {
data := SitesData{Error: message}
h.Templates.Render(w, tmplName, data)
}
// renderCreateFormRefusal re-renders the create form at 422 with the
// declaration in submission mode: every submitted value carried back, each
// field's error under its control, and a refusal that belongs to no field
// in the form-level slot the part always renders (design D9; findings
// FA-17, FA-22, FA-27).
func (h *FedWikiPartialsHandler) renderCreateFormRefusal(w http.ResponseWriter, r *http.Request, workspaceID string, values forms.Values, errs *forms.Errors) {
w.WriteHeader(http.StatusUnprocessableEntity)
customDomains, commitState := h.resolveCreateFormAffordances(r.Context(), workspaceID)
// customDomainsEnabled is set fresh from the workspace's current state,
// never read back from the submission (finding FA-10): a refused
// resubmission must not let a stale or tampered value reopen an
// affordance the workspace does not currently have.
enabledRaw := ""
if customDomains {
enabledRaw = "true"
}
values.Set("customDomainsEnabled", enabledRaw)
view := forms.Render(siteCreateForm, forms.Binding{
Mode: forms.ModeSubmission,
Values: values,
Errors: errs,
Options: siteCreateOptions(h.FedWikiAllowedDomains),
})
view.CommitState = commitState
h.Templates.Render(w, "fedwiki_create_form.html", CreateFormData{
AllowedDomains: h.FedWikiAllowedDomains,
Form: view,
})
}
func (h *FedWikiPartialsHandler) getWorkspaceQuota(ctx context.Context, workspaceID string) (*workspaceQuota, error) {
assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
ent, err := h.EntitlementsQ.GetNumericEntitlementByPoolAndResource(ctx, entitlements.GetNumericEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: siteusage.ResourceKey,
})
if err != nil {
return nil, err
}
// Rows, not the reservation counter, are what the member is shown
// (design D1): the counter is a create-time reservation and an import or
// a farm sync writes rows without touching it.
// The pool and entitlement lookups above run first on purpose: a handler
// built without a site store (the zero-domains render tests) fails on the
// missing pool row instead of dereferencing a nil SiteQ here.
current, err := siteusage.ActiveSites(ctx, h.SiteQ, workspaceID)
if err != nil {
return nil, err
}
return &workspaceQuota{
currentUsage: current,
resourceLimit: ent.ResourceLimit,
}, nil
}