// Package config is the one place the process reads its environment. // // Two jobs, in order: // // 1. Pick the right `.env` file for the environment we are in and load it. // 2. Read every setting into a typed Config and refuse to start if anything // required is missing — all of it, in one message, before a single // connection is attempted. // // Before this, `main.go` loaded `.env` and nothing else. `.env.local` and // `.env.production` described an `APP_ENV` switch that did not exist, so the // only way to run against production was to overwrite `.env` by hand, and the // only way to find out a variable was missing was a `log.Fatalf` from inside // `db.Connect()` — one variable per restart. // // # Which file loads // // `APP_ENV` names the environment and defaults to "local": // // go run . → .env.local, then .env // APP_ENV=production go run . → .env.production, then .env // // `.env` is a shared base loaded after the environment file. godotenv never // overwrites a variable that is already set, so the order of precedence is: // // real environment > .env. > .env // // Neither file has to exist. On the deployed host every value comes from the // platform's environment settings (Dokploy today, ConfigMaps/Secrets under // Kubernetes) and there is no file at all — which is exactly why the // Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the // image carries no `.env.*`, so it cannot fall back to localhost values that // happen to be lying around in the build context. package config import ( "errors" "fmt" "log" "os" "strconv" "strings" "github.com/joho/godotenv" ) // Environment names. Anything else is accepted (a staging file works the same // way) but only these two change behaviour. const ( EnvLocal = "local" EnvProduction = "production" ) // Config is everything the process reads from its environment. // // A few settings are still read directly with os.Getenv at the point of use, // because they are consulted per request or per connection rather than once // at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED // (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_* // and POS_* settings in package messaging. They are listed and validated here // so that a misconfiguration is still caught at startup. type Config struct { // AppEnv is the value of APP_ENV: "local" or "production". AppEnv string // Port the API listens on. APP_PORT, default 1122 (production sets 1009). Port string DB DBConfig Catalogue DBConfig // Host empty → catalogue endpoints disabled. Redis RedisConfig S3 S3Config MQTT MQTTConfig Embedding EmbeddingConfig // Assistant is the model behind Nearle Buddy. Empty provider = no typed // questions; the tools still work. Assistant AssistantConfig // POSTokenSecret signs terminal sessions. Falls back to JWTSecret when // unset, matching utils/postoken.go. POSTokenSecret string JWTSecret string UserContextKey string GeocoderAPIKey string } // DBConfig is one Postgres connection. type DBConfig struct { Host string Port string Name string User string Password string } // Enabled reports whether a host was configured at all. Only meaningful for // the optional catalogue connection; the main database is required. func (d DBConfig) Enabled() bool { return d.Host != "" } // RedisConfig is the shared Redis used for POS terminal presence. Optional: // Host empty means presence is disabled and bills still commit. type RedisConfig struct { Host string Port string User string Password string DB int } func (r RedisConfig) Enabled() bool { return r.Host != "" } // S3Config is the DigitalOcean Spaces bucket holding catalogue product images. type S3Config struct { Enabled bool // USE_S3=true Endpoint string Bucket string AccessKey string SecretKey string Region string } // MQTTConfig is the broker the in-store tills publish to. Optional: URL empty // means the MQTT ingest and the console live stream stay quiet. type MQTTConfig struct { URL string User string Password string ClientID string } func (m MQTTConfig) Enabled() bool { return m.URL != "" } // EmbeddingConfig is the text-embedding model behind the scan-to-product // search (services/scanService.go). It MUST be the model that filled the // catalogue's `embedding` column — vectors from two different models are not // comparable, and pgvector will happily rank garbage. Optional: with no // provider the search falls back to plain text matching. type EmbeddingConfig struct { Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini" Model string APIKey string BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1 Dimensions int // 0 = the model's default } func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" } // AssistantConfig is the model behind Nearle Buddy. // // Optional, like the embedder. With no provider the assistant refuses typed // questions and says so — the tools still work and still answer correctly, // because they are ordinary Go functions; only the part that turns a sentence // into a tool call is missing. // // ── Why three models and not one ──────────────────────────────────────────── // // An agent names a TIER, never a model. "Which branch is underperforming?" // and "why is the cancel rate high?" want different amounts of thinking, and // wiring a model name into an agent means changing every agent to change // provider. The tiers are the stable vocabulary; this map is the only place a // model name appears. // // `ASSISTANT_MODEL` alone sets all three, which is the sane default for a // deployment that has not thought about it yet. type AssistantConfig struct { Provider string // "openai" — any OpenAI-compatible endpoint BaseURL string // default https://api.openai.com/v1 APIKey string // Tier → model name. Empty falls back to Balanced, which falls back to // ASSISTANT_MODEL. Fast string Balanced string Deep string } func (a AssistantConfig) Enabled() bool { return a.Why() == "" } // Why says what is missing, or "" when the assistant can run. // // A sentence rather than a bool, because "off" is the same answer for four // different mistakes: no provider, no model, no key, a provider nobody // recognises. Without this the only symptom is a disabled composer, and the // difference between "we have not switched it on" and "somebody misspelled a // variable" is invisible from the outside — which is exactly where this was // stuck. // The model is reported before the provider, and that order matters. Since // `assistantProvider` derives the provider from the model, an empty provider // means the model is empty too — and naming ASSISTANT_PROVIDER first would send // an operator to set a variable they no longer need, while the one they // actually missed went unmentioned. func (a AssistantConfig) Why() string { if a.Balanced == "" { return "ASSISTANT_MODEL is not set; give it the provider's model name, " + "for example openai/gpt-oss-120b" } switch a.Provider { case "openai", "groq", "ollama", "together", "compatible": case "": // Not reachable through Load, which derives it. Reachable when // something builds this struct by hand, and silence would be worse. return "ASSISTANT_PROVIDER is not set and could not be derived" default: return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks" } // A local provider needs no credential; a hosted one always does, and a // missing key otherwise surfaces as a 401 from the provider on the first // question rather than as a configuration problem. if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) { where := a.BaseURL if where == "" { // Empty means the OpenAI default, which is emphatically not local. // "and is not a local endpoint" is how that read before. where = "the default https://api.openai.com/v1" } return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint" } return "" } // isLocalEndpoint reports whether a base URL is something running beside us. // // Ollama and LM Studio need no key, and demanding one would refuse the setup a // developer is most likely to have on their own machine. func isLocalEndpoint(baseURL string) bool { url := strings.ToLower(baseURL) return strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") || strings.Contains(url, "host.docker.internal") } // ModelFor resolves a tier to a model name, falling back rather than failing. // // A missing `fast` model should answer a cheap question with the balanced one, // not refuse it. A deployment that sets one model gets one model everywhere. func (a AssistantConfig) ModelFor(tier string) string { switch tier { case "fast": if a.Fast != "" { return a.Fast } case "deep": if a.Deep != "" { return a.Deep } } return a.Balanced } // What Nearle Buddy runs on unless a deployment says otherwise. // // These are in the code rather than in the environment because they are not // secrets and not deployment-specific — they are what this product uses. Every // variable that has one right answer is a variable somebody has to remember, // get past a platform's UI, and then re-enter on the next environment; three of // the four were exactly that, and the assistant sat switched off for days // because one of them had not been typed. // // The API key is the one that genuinely varies and genuinely cannot live here. const ( defaultAssistantProvider = "openai" defaultAssistantBaseURL = "https://api.groq.com/openai/v1" defaultAssistantModel = "openai/gpt-oss-120b" ) // assistantProvider reads the provider, defaulting to the one shape this // server speaks. // // Every endpoint here is OpenAI-compatible — Groq, Ollama, Together and OpenAI // itself — so the base URL is what actually distinguishes them. Naming a // protocol you have no choice about is a variable that exists only to be // forgotten. func assistantProvider() string { if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" { return named } return defaultAssistantProvider } // AssistantFromEnv reads the assistant's settings, defaults and all. // // Exported and used by `Load` rather than written inline there, because the // live tests need the SAME reading. They used to build this struct by hand from // `os.Getenv`, which meant they skipped silently the moment a default was // introduced — they were testing a configuration production no longer uses, // and the one time that mattered was the day the provider stopped being // required and nothing noticed. // // Three of the four fields have one right answer and come from the constants // above. The key varies between deployments and is the only one that cannot // live in this repository. func AssistantFromEnv() AssistantConfig { return AssistantConfig{ Provider: assistantProvider(), BaseURL: env("ASSISTANT_BASE_URL", defaultAssistantBaseURL), APIKey: env("ASSISTANT_API_KEY", ""), Fast: env("ASSISTANT_MODEL_FAST", ""), // ASSISTANT_MODEL alone still sets every tier, for a deployment that // wants one model everywhere but not this one. Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", defaultAssistantModel)), Deep: env("ASSISTANT_MODEL_DEEP", ""), } } // IsProduction is true under APP_ENV=production. func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction } // Load picks and loads the environment files, reads every setting and // validates them. The returned error lists every problem at once. func Load() (*Config, error) { loadEnvFiles() cfg := &Config{ AppEnv: env("APP_ENV", EnvLocal), Port: env("APP_PORT", "1122"), DB: DBConfig{ Host: env("DB_HOST", ""), Port: env("DB_PORT", "5433"), Name: env("DB_NAME", ""), User: env("DB_USER", ""), Password: env("DB_PASSWORD", ""), }, Catalogue: DBConfig{ Host: env("CATALOGUE_DB_HOST", ""), Port: env("CATALOGUE_DB_PORT", "5432"), Name: env("CATALOGUE_DB_NAME", ""), User: env("CATALOGUE_DB_USER", ""), Password: env("CATALOGUE_DB_PASSWORD", ""), }, Redis: RedisConfig{ Host: env("REDIS_HOST", ""), Port: env("REDIS_PORT", "6379"), User: env("REDIS_USER", "default"), Password: env("REDIS_PASSWORD", ""), }, S3: S3Config{ Enabled: strings.EqualFold(env("USE_S3", ""), "true"), Endpoint: env("S3_ENDPOINT", ""), Bucket: env("S3_BUCKET", ""), AccessKey: env("S3_ACCESS_KEY", ""), SecretKey: env("S3_SECRET_KEY", ""), Region: env("S3_REGION", ""), }, MQTT: MQTTConfig{ URL: env("MQTT_URL", ""), // MQTT_USERNAME is accepted because livehub.go read that name for // a while, so an existing deployment may still set it. User: env("MQTT_USER", env("MQTT_USERNAME", "")), Password: env("MQTT_PASSWORD", ""), ClientID: env("MQTT_CLIENT_ID", ""), }, Embedding: EmbeddingConfig{ Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")), Model: env("EMBEDDING_MODEL", ""), APIKey: env("EMBEDDING_API_KEY", ""), BaseURL: env("EMBEDDING_BASE_URL", ""), }, Assistant: AssistantFromEnv(), POSTokenSecret: env("POS_TOKEN_SECRET", ""), JWTSecret: env("JWT_SECRET_KEY", ""), UserContextKey: env("USER_CONTEXT_KEY", "nearle"), GeocoderAPIKey: env("GEOCODER_API_KEY", ""), } if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil { cfg.Redis.DB = db } else { return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", "")) } if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" { n, err := strconv.Atoi(dims) if err != nil || n < 0 { return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims) } cfg.Embedding.Dimensions = n } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } // MustLoad is Load for main(): every problem is printed and the process exits. func MustLoad() *Config { cfg, err := Load() if err != nil { log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err) } log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s", cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name) return cfg } // validate collects every problem rather than stopping at the first, so one // restart is enough to learn everything that is wrong. func (c *Config) validate() error { var problems []string missing := func(key string) { problems = append(problems, " - "+key+" is required") } if c.DB.Host == "" { missing("DB_HOST") } if c.DB.User == "" { missing("DB_USER") } if c.DB.Password == "" { missing("DB_PASSWORD") } if c.DB.Name == "" { missing("DB_NAME") } // Optional subsystems are either fully configured or absent. Half a // configuration used to be skipped with a warning, which reads as "fine" // in a log and turns into "why are there no images" a week later. if c.Catalogue.Enabled() { if c.Catalogue.User == "" { missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)") } if c.Catalogue.Password == "" { missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)") } if c.Catalogue.Name == "" { missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)") } } if c.S3.Enabled { if c.S3.Endpoint == "" { missing("S3_ENDPOINT (USE_S3=true)") } if c.S3.Bucket == "" { missing("S3_BUCKET (USE_S3=true)") } if c.S3.AccessKey == "" { missing("S3_ACCESS_KEY (USE_S3=true)") } if c.S3.SecretKey == "" { missing("S3_SECRET_KEY (USE_S3=true)") } if c.S3.Region == "" { missing("S3_REGION (USE_S3=true)") } } if c.Embedding.Enabled() { switch c.Embedding.Provider { case "openai", "gemini": default: problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider) } if c.Embedding.Model == "" { missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)") } if c.Embedding.APIKey == "" { missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)") } } if c.IsProduction() { // utils/postoken.go refuses to sign with a short secret at request // time; catching it here means the first till login is not the first // anyone hears of it. secret := c.POSTokenSecret if secret == "" { secret = c.JWTSecret } if strings.TrimSpace(secret) == "" { missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)") } else if len(strings.TrimSpace(secret)) < 16 { problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters") } } else if c.DB.Host != "" && !isLocalHost(c.DB.Host) { // Not fatal: a dump restored on another machine on the LAN is a valid // local setup. But `.env.local` pointing at the live host is the // mistake every comment in that file warns about, so say it out loud. log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real", c.AppEnv, c.DB.Host) } if len(problems) == 0 { return nil } return errors.New(strings.Join(problems, "\n")) } // loadEnvFiles loads `.env.` and then `.env`, each only if present. // // APP_ENV is read from the real environment before any file, so a file cannot // change which environment it is loaded for. // `.env.secrets` is read FIRST and is the only one of these git does not track. // godotenv never overwrites a value already set, so first read wins — which is // what makes this file the place a key belongs. Every other file here is in the // repository, so a secret written to one is a secret published; there was // previously nowhere to put a key at all, and the answer was "export it in your // shell every time", which is the kind of instruction people route around. // envFileOrder is the read order, and the order is the rule: godotenv never // overwrites a value already set, so whichever file names a variable first is // the one that decides it. func envFileOrder(appEnv string) []string { return []string{".env.secrets", ".env." + appEnv, ".env"} } func loadEnvFiles() { for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) { if _, err := os.Stat(name); err != nil { continue } if err := godotenv.Load(name); err != nil { log.Printf("config: could not read %s: %v", name, err) continue } log.Printf("config: loaded %s", name) } } func env(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func isLocalHost(host string) bool { switch strings.ToLower(host) { case "localhost", "127.0.0.1", "::1", "host.docker.internal": return true } return strings.HasPrefix(host, "127.") }