package server import ( "database/sql" "fmt" "log/slog" "net/http" "net/url" "strings" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/integration" "github.com/spf13/viper" ) // The integration settings surface: one handler/template pair renders every // installed integration's declared configuration (ConfigSpec) and manages // non-secret override rows in core.integration_config_overrides. Overrides // apply on restart (see internal/config.ApplyOverlay); the page derives its // "pending restart" indicator by comparing stored overrides against the // boot-effective snapshot. // SettingRow is one declared configuration key, prepared for rendering. type SettingRow struct { Key string Usage string Secret bool Enum []string Kind string // input shape: "enum" | "bool" | "list" | "string" Required bool // Effective is the boot-effective value (what the running process // uses) in display form; Source is the layer it came from. Effective string Source string // Override is the stored override value ("" when none); a stored // override that has not taken effect yet (or a cleared one that is // still in effect) sets PendingRestart. Override string HasOverride bool PendingRestart bool // SecretSet reports whether a Secret key currently resolves to a // non-blank value (environment or "-file"), without ever exposing that // value — the settings page distinguishes "set (masked)" from "not // set" instead of showing the same "•••" treatment for both (ux- // honest-surfaces integration-settings spec, "Secret settings // distinguish set from unset"). Meaningless when Secret is false. SecretSet bool // Warning is optional consequence copy rendered adjacent to the // control, before submission, for a setting whose value change has a // real-world effect the operator should see up front (today: only // stripe-mode, set by name in GetIntegrationSettingsPage). Empty for // every routine key. Warning string } // IntegrationSettingsData is the settings page body. type IntegrationSettingsData struct { Slug string DisplayName string SurfacePath string // admin page to cross-link ("" when none) Rows []SettingRow // Saved/Cleared/Error/ErrorKey carry post-redirect feedback (?saved= / // ?cleared= / ?error= / ?key= query params — the write path answers // with HX-Redirect). Saved string Cleared string Error string ErrorKey string } // integrationConfigBySlug returns the declared config for one installed // integration, or false when the slug is unknown or declares no keys. func (h *OperatorPartialsHandler) integrationConfigBySlug(slug string) (IntegrationConfigInfo, bool) { for _, info := range h.IntegrationConfigs { if info.Slug == slug && len(info.Keys) > 0 { return info, true } } return IntegrationConfigInfo{}, false } // configurationReadiness reports whether provider slug's declared required // configuration is fully resolved, and names any unresolved keys. // // Both the Integrations list (GetIntegrationsPage) and the landing // surface's System region (loadOverviewProviders) call this — the SAME // required-key resolution the settings page itself performs // (config.RequiredKeysUnresolved) — so "not configured" means the same // thing everywhere it renders (ux-honest-surfaces design decision 2). A // provider absent from configs, or with no required-together group at all, // is vacuously configured: there is nothing an operator could set. func configurationReadiness(configs []IntegrationConfigInfo, slug string) (configured bool, missing []string) { for _, info := range configs { if info.Slug == slug { missing = config.RequiredKeysUnresolved(info.Keys) return len(missing) == 0, missing } } return true, nil } // settingKind maps a declaration to its input shape. func settingKind(key config.ConfigKey) string { if len(key.Enum) > 0 { return "enum" } switch key.Default.(type) { case []string: return "list" case bool: return "bool" default: return "string" } } // normalizedOverride renders a stored override in the same display form as // the boot snapshot, so pending-restart comparison is encoding-insensitive // for list keys (" a, b " vs "a,b"). func normalizedOverride(key config.ConfigKey, raw string) string { if _, ok := key.Default.([]string); ok { return strings.Join(config.SplitList(raw), ",") } return raw } // GetIntegrationSettingsPage handles GET /operator/integrations/{slug}/settings. func (h *OperatorPartialsHandler) GetIntegrationSettingsPage(w http.ResponseWriter, r *http.Request) { info, ok := h.integrationConfigBySlug(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } overrides := map[string]string{} rows, err := integration.New(h.Database).ListConfigOverrides(r.Context()) bodyData := IntegrationSettingsData{ Slug: info.Slug, DisplayName: info.DisplayName, SurfacePath: info.SurfacePath, Saved: r.URL.Query().Get("saved"), Cleared: r.URL.Query().Get("cleared"), Error: r.URL.Query().Get("error"), ErrorKey: r.URL.Query().Get("key"), } if err != nil { h.Logger.Error("failed to list config overrides", slog.Any("error", err)) bodyData.Error = "Failed to load stored overrides; showing declared configuration only." } else { for _, row := range rows { overrides[row.Key] = row.Value } } boot := config.BootEffective() for _, key := range info.Keys { row := SettingRow{ Key: key.Name, Usage: key.Usage, Secret: key.Secret, Enum: key.Enum, Kind: settingKind(key), Required: key.RequiredGroup != "", } if key.Secret { // Presence only, never the value: viper still resolves secrets // (cmd/start.go binds their "-file" companion the same as any // other key), they just never enter the boot-effective snapshot // or get echoed back. This is the same resolution // config.RequiredKeysUnresolved reads for the readiness signal // shown on the Integrations list and overview, so "set" here // always agrees with "configured" there. row.SecretSet = strings.TrimSpace(viper.GetString(key.Name)) != "" } else { eff := boot[key.Name] row.Effective = eff.Value row.Source = string(eff.Source) if stored, has := overrides[key.Name]; has { row.Override = stored row.HasOverride = true row.PendingRestart = eff.Source != config.SourceOverride || eff.Value != normalizedOverride(key, stored) } else { row.PendingRestart = eff.Source == config.SourceOverride } } if key.Name == "stripe-mode" { // The one setting on this generic page whose value has a // real-world consequence beyond this deployment's own state: // switching to live processes real charges. Disclosed here, // adjacent to the control, before the operator ever submits — // see the integration-settings spec's stripe-mode requirement. row.Warning = "Selecting live moves this deployment onto real payment processing: charges become real money, not test transactions." } bodyData.Rows = append(bodyData.Rows, row) } page := h.buildOperatorPageData(r) page.IAPosition = "integration:integrations:" + info.Slug page.ActiveCapability = "integrations" page.BodyTemplate = "operator_integration_settings.html" page.BodyData = bodyData h.Templates.Render(w, "operator.html", page) } // PostIntegrationSetting handles POST /operator/integrations/{slug}/settings — // save or clear one key's override. Answers with HX-Redirect back to the // settings page (the operator shell posts via HTMX with the CSRF header); // feedback travels as query params. func (h *OperatorPartialsHandler) PostIntegrationSetting(w http.ResponseWriter, r *http.Request) { info, ok := h.integrationConfigBySlug(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } settingsPath := "/operator/integrations/" + info.Slug + "/settings" redirect := func(query string) { w.Header().Set("HX-Redirect", settingsPath+query) w.WriteHeader(http.StatusOK) } keyName := r.FormValue("key") var spec config.ConfigKey found := false for _, k := range info.Keys { if k.Name == keyName { spec, found = k, true break } } if !found { redirect("?error=" + url.QueryEscape("Unknown configuration key.") + "&key=" + url.QueryEscape(keyName)) return } if spec.Secret { redirect("?error=" + url.QueryEscape("Secret keys are managed via the environment and cannot be overridden.") + "&key=" + url.QueryEscape(keyName)) return } iq := integration.New(h.Database) switch r.FormValue("action") { case "clear": if _, err := iq.DeleteConfigOverride(r.Context(), keyName); err != nil { h.Logger.Error("failed to clear config override", slog.String("key", keyName), slog.Any("error", err)) redirect("?error=" + url.QueryEscape("Failed to clear the override.") + "&key=" + url.QueryEscape(keyName)) return } redirect("?cleared=" + url.QueryEscape(keyName)) return case "save": value := strings.TrimSpace(r.FormValue("value")) if value == "" { redirect("?error=" + url.QueryEscape("Provide a value; an empty override cannot be stored.") + "&key=" + url.QueryEscape(keyName)) return } // Same validation the boot overlay applies: a value that saves is // a value that boots. if _, err := config.CoerceOverride(spec, value); err != nil { redirect("?error=" + url.QueryEscape(err.Error()) + "&key=" + url.QueryEscape(keyName)) return } updatedBy := sql.NullString{} if page := h.buildOperatorPageData(r); page.Email != "" { updatedBy = sql.NullString{String: page.Email, Valid: true} } if err := iq.UpsertConfigOverride(r.Context(), integration.UpsertConfigOverrideParams{ Key: keyName, Value: value, UpdatedBy: updatedBy, }); err != nil { h.Logger.Error("failed to save config override", slog.String("key", keyName), slog.Any("error", err)) redirect("?error=" + url.QueryEscape("Failed to save the override.") + "&key=" + url.QueryEscape(keyName)) return } default: redirect("?error=" + url.QueryEscape("Unknown action.")) return } redirect("?saved=" + url.QueryEscape(keyName)) } // OperatorNotFoundData is the body for the operator-subtree 404 page. type OperatorNotFoundData struct { Path string } // GetOperatorNotFound handles every /operator/* path no more-specific route // claims (the "/operator/" subtree pattern loses to all registered pages). // Without it, unmatched operator paths fall through to the "/" catch-all and // render the MEMBER dashboard with a 200 — a truncated URL like // /operator/integrations/stripe would silently eject the operator from the // operator area (2026-07-22 fresh-eyes audit, finding: soft-404 trap). func (h *OperatorPartialsHandler) GetOperatorNotFound(w http.ResponseWriter, r *http.Request) { page := h.buildOperatorPageData(r) page.IAPosition = "runtime:landing" page.BodyTemplate = "operator_not_found.html" page.BodyData = OperatorNotFoundData{Path: r.URL.Path} // Content-Type must precede WriteHeader; Render's own Set afterwards // would be ignored on this non-200 path. w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusNotFound) h.Templates.Render(w, "operator.html", page) } // DeleteOrphanOverride handles DELETE /operator/integrations/config-orphans/{key} // — remove an override row whose key matches no installed integration's // declaration (listed on the Integrations landing, confirmed via the shared // confirm-action modal). func (h *OperatorPartialsHandler) DeleteOrphanOverride(w http.ResponseWriter, r *http.Request) { keyName := r.PathValue("key") // Refuse to delete a row that is NOT an orphan through this path — the // per-integration settings page owns declared keys. for _, info := range h.IntegrationConfigs { for _, k := range info.Keys { if k.Name == keyName { http.Error(w, fmt.Sprintf("key %q belongs to integration %q; manage it on its settings page", keyName, info.Slug), http.StatusBadRequest) return } } } if _, err := integration.New(h.Database).DeleteConfigOverride(r.Context(), keyName); err != nil { h.Logger.Error("failed to delete orphan config override", slog.String("key", keyName), slog.Any("error", err)) http.Error(w, "failed to delete override", http.StatusInternalServerError) return } w.Header().Set("HX-Redirect", "/operator/integrations") w.WriteHeader(http.StatusOK) }