// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package integration import ( "context" "database/sql" "fmt" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" ) // SettingsService is the one seam onto core.integration_config_overrides // (design D11, typed-config-keys): the operator settings page's save, // Clear and orphan-delete handlers and `member-console config` both call // it instead of the generated store directly, so the two surfaces cannot // behave differently. It lives here, in internal/integration (the package // that already owns the generated ConfigOverride store), rather than in // internal/config or internal/server: internal/config declares the parser // and the boot overlay and must stay free of any *sql.DB dependency (it is // imported by cmd before a database connection exists, in CheckSpecs and // ValidateStart), and internal/server cannot be imported by cmd/settings.go // without pulling in the whole HTTP surface. internal/integration already // imports internal/config for nothing today, and the reverse import // (internal/config importing internal/integration) does not exist and must // not start: this file's only new dependency edge is internal/integration // -> internal/config, which both cmd and internal/server can already reach. type SettingsService struct { q *Queries } // NewSettingsService builds a SettingsService backed by db, the same way // New builds the generated Queries it wraps. func NewSettingsService(db DBTX) *SettingsService { return &SettingsService{q: New(db)} } // SettingsRow is one key's state as List reports it: enough for both the // operator settings page's own rendering (which this service does not // produce, since GetIntegrationSettingsPage keeps reading // ListConfigOverrides directly and layering config.BootEffective, the // boot-time snapshot no separate CLI process has) and `member-console // config list`, which has no boot-effective snapshot to read and // instead states what this process's own environment/flag/config-file/ // default resolution gives. type SettingsRow struct { Key string // Declared is false when Key matches no passed-in ConfigKey: a stored // override left behind by an integration that has since been // uninstalled, or renamed its key (the orphan case the operator // Integrations page already lists; design D11 requires the CLI list it // too, "unrecognized rows listed as such"). Declared bool Secret bool // Environment is the value this process's environment, flags, config // file, or declared default gives Key, computed with // config.EffectiveString the same way ApplyOverlay's pre-overlay pass // does: never the boot-effective value a running server already // layered an override over. Empty for a secret key (never echoed) and // for an unrecognized row (nothing declares it, so nothing resolves // it). Environment string // SecretSet reports, for a Secret key only, whether the environment // currently resolves it to a non-blank value, without ever exposing // that value (mirrors the operator page's own SettingRow.SecretSet). SecretSet bool // HasOverride and Override report the stored row, if any, in its raw // (transport-encoded) form. A secret key can carry a stored row only if // one was written before this design forbade it (Set below refuses // one going forward); ApplyOverlay refuses to boot on it either way, so // Override is reported here without ever being echoed unmasked to a // terminal (callers mask it, exactly like Environment). HasOverride bool Override string // Winner is the source that wins at the next boot: SourceOverride when // HasOverride is true, otherwise SourceEnvironment or SourceDefault the // way ApplyOverlay's own source attribution decides (design D11). A // secret key's stored override never actually wins, since ApplyOverlay // refuses to boot on it, so its Winner is always SourceEnvironment, // which is also the only source a secret key's value can ever come // from. Meaningless (left "") when Declared is false: ApplyOverlay // skips an unrecognized row outright, so it never wins anything. Winner config.Source } // List reports one SettingsRow per key in specs, in declaration order, // followed by one row for every stored override whose key matches no spec // (declaration order is not meaningful for those, so they are appended in // the order ListConfigOverrides returns them: by key). specs is the // caller's full aggregate of installed integrations' ConfigSpec; passing a // narrower set would misreport another integration's stored override as // unrecognized, so a caller that wants only one integration's rows (the // CLI's `list ` filter) filters List's result instead of // narrowing specs. func (s *SettingsService) List(ctx context.Context, specs []config.ConfigKey) ([]SettingsRow, error) { stored, err := s.q.ListConfigOverrides(ctx) if err != nil { return nil, err } overrides := make(map[string]string, len(stored)) for _, row := range stored { overrides[row.Key] = row.Value } seen := make(map[string]bool, len(specs)) rows := make([]SettingsRow, 0, len(specs)) for _, spec := range specs { seen[spec.Name] = true row := SettingsRow{Key: spec.Name, Declared: true, Secret: spec.Secret} if val, has := overrides[spec.Name]; has { row.HasOverride = true row.Override = val } if spec.Secret { row.SecretSet = config.EffectiveString(spec) != "" row.Winner = config.SourceEnvironment rows = append(rows, row) continue } row.Environment = config.EffectiveString(spec) switch { case row.HasOverride: row.Winner = config.SourceOverride case row.Environment != config.DefaultString(spec): row.Winner = config.SourceEnvironment default: row.Winner = config.SourceDefault } rows = append(rows, row) } for _, stored := range stored { if seen[stored.Key] { continue } rows = append(rows, SettingsRow{ Key: stored.Key, Declared: false, HasOverride: true, Override: stored.Value, // Winner left unset: nothing declares this key, so ApplyOverlay // skips the row at boot rather than applying it. }) } return rows, nil } // Set writes key's override after refusing an unknown key, a secret key, or // a value that fails to parse against the key's declaration, through the // same config.Parse the boot overlay and the operator page's save path // apply, so a value that saves here is a value that boots (design D2, // D11). raw is the transport encoding (the same one the environment and // the settings page's control use); updatedBy is stored as the acting // operator's identity, or "" for the CLI, which has none to record // (mirrors internal/instance.Store's own convention for an unattributed // write). func (s *SettingsService) Set(ctx context.Context, specs []config.ConfigKey, key, raw, updatedBy string) error { spec, found := findSpec(specs, key) if !found { return fmt.Errorf("unknown key %s", key) } if spec.Secret { return fmt.Errorf("%s is a secret key and is set in the environment", key) } if _, err := config.Parse(spec, raw); err != nil { return err } by := sql.NullString{} if updatedBy != "" { by = sql.NullString{String: updatedBy, Valid: true} } return s.q.UpsertConfigOverride(ctx, UpsertConfigOverrideParams{ Key: key, Value: raw, UpdatedBy: by, }) } // Clear deletes key's stored override, declared or not (an unrecognized // row is removable through this the same way a declared one is, design // D11: "clear ... an unrecognized row included"), and reports whether a // row actually existed to delete. func (s *SettingsService) Clear(ctx context.Context, key string) (found bool, err error) { rows, err := s.q.DeleteConfigOverride(ctx, key) if err != nil { return false, err } return rows > 0, nil } // findSpec returns the ConfigKey named key from specs, and whether one was // found. func findSpec(specs []config.ConfigKey, key string) (config.ConfigKey, bool) { for _, spec := range specs { if spec.Name == key { return spec, true } } return config.ConfigKey{}, false }