// 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 // 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 != "" } // 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", ""), }, 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. func loadEnvFiles() { appEnv := env("APP_ENV", EnvLocal) for _, name := range []string{".env." + appEnv, ".env"} { 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.") }