diff --git a/internal/config/config.go b/internal/config/config.go index ea8c0b712..73eb7a0ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -896,6 +896,11 @@ func Load() (*Config, error) { if len(envTokens) > 0 { cfg.EnvOverrides["API_TOKEN"] = true cfg.EnvOverrides["API_TOKENS"] = true + + // Track if we migrated any new tokens from env to persistence + migratedCount := 0 + needsPersist := false + for _, tokenValue := range envTokens { if tokenValue == "" { continue @@ -909,18 +914,18 @@ func Load() (*Config, error) { hashed = auth.HashAPIToken(tokenValue) prefix = tokenPrefix(tokenValue) suffix = tokenSuffix(tokenValue) - log.Info().Msg("Auto-hashed plain text API token from environment variable") - } else { - log.Debug().Msg("Loaded pre-hashed API token from env var") + log.Debug().Msg("Auto-hashed plain text API token from environment variable") } + // Check if this token already exists in api_tokens.json if cfg.HasAPITokenHash(hashed) { continue } + // Migrate env token to api_tokens.json record := APITokenRecord{ ID: uuid.NewString(), - Name: "Environment token", + Name: "Migrated from .env (" + prefix + ")", Hash: hashed, Prefix: prefix, Suffix: suffix, @@ -928,8 +933,22 @@ func Load() (*Config, error) { Scopes: []string{ScopeWildcard}, } cfg.APITokens = append(cfg.APITokens, record) + migratedCount++ + needsPersist = true } + cfg.SortAPITokens() + + // Persist migrated tokens to api_tokens.json + if needsPersist && persistence != nil { + if err := persistence.SaveAPITokens(cfg.APITokens); err != nil { + log.Error().Err(err).Msg("Failed to persist migrated API tokens from environment") + } else { + log.Warn(). + Int("count", migratedCount). + Msg("Migrated API tokens from .env to api_tokens.json - API_TOKEN/API_TOKENS in .env are deprecated and will be ignored in future releases. Manage tokens via the UI instead.") + } + } } // Check if API token is enabled diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 6bf992ffd..57ee6f1fc 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -335,7 +335,8 @@ func (cw *ConfigWatcher) reloadConfig() { } } - // Apply API tokens if present in .env (legacy support) + // Legacy env token support: only process if api_tokens.json is empty + // This prevents .env changes from overwriting UI-managed tokens (fixes #685) rawTokens := make([]string, 0, 4) if raw, ok := envMap["API_TOKENS"]; ok { raw = strings.Trim(raw, "'\"") @@ -347,17 +348,19 @@ func (cw *ConfigWatcher) reloadConfig() { rawTokens = append(rawTokens, token) } } - } else { - // Explicit empty list clears tokens - rawTokens = []string{} } } if raw, ok := envMap["API_TOKEN"]; ok { raw = strings.Trim(raw, "'\"") - rawTokens = append(rawTokens, raw) + if raw != "" { + rawTokens = append(rawTokens, raw) + } } - if len(rawTokens) > 0 { + // Only reload tokens from .env if NO tokens exist in api_tokens.json + // This makes api_tokens.json the authoritative source once it has records + if len(rawTokens) > 0 && len(cw.config.APITokens) == 0 { + log.Debug().Msg("No existing API tokens found - loading from .env (legacy)") seen := make(map[string]struct{}, len(rawTokens)) newRecords := make([]APITokenRecord, 0, len(rawTokens)) for _, tokenValue := range rawTokens { @@ -416,6 +419,8 @@ func (cw *ConfigWatcher) reloadConfig() { } } } + } else if len(rawTokens) > 0 && len(cw.config.APITokens) > 0 { + log.Debug().Msg("Ignoring API_TOKEN/API_TOKENS from .env - api_tokens.json is authoritative") } // REMOVED: POLLING_INTERVAL from .env - now ONLY in system.json