package config import ( "net/url" "os" "strings" "time" ) // Config holds all runtime configuration loaded from environment variables. // // Variable names follow the cluster/CI contract documented in .env.sample // (PGHOST/PGUSER/…, AWS_ACCESS_KEY_ID/…, S3_ENDPOINT/S3_BUCKET/S3_PREFIX) so the // same binary runs unchanged in the Special Partners infra. Local docker-compose // supplies the same variables. This mirrors the sister eQMS (Mallard) service. type Config struct { Port string DatabaseURL string S3Endpoint string S3PublicEndpoint string S3Region string S3Bucket string S3Prefix string S3AccessKey string S3SecretKey string DevAuth bool SeedData bool // FCMCredentialsFile is the path to a Firebase service-account JSON used to // send push notifications to spin-mobile. Empty = push disabled (no-op). FCMCredentialsFile string // GoogleSACredentialsFile is the path to a Google Workspace service-account // JSON with DOMAIN-WIDE DELEGATION (gmail.readonly) used to read project // client-domain mail. Empty = mail integration disabled. GoogleSACredentialsFile string // MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL, // e.g. "15m"). 0 disables periodic sync (on-demand backfill still works). MailSyncInterval time.Duration // OpenAIKey enables short AI mail summaries (OPENAI_API_KEY). Empty = disabled. OpenAIKey string // LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL): // ends the oauth2-proxy session then redirects to Keycloak end-session. // The static frontend reads it via /me. Defaults to the infra-common value. LogoutURL string // AdminGroups are the Keycloak groups whose members are super-admins // (manage everything across the company: incentive console, accounting, // approvals, all member/project data). AdminGroups []string } func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // parseDur parses a Go duration string (e.g. "15m"); invalid/"0" yields 0 (disabled). func parseDur(s string) time.Duration { d, err := time.ParseDuration(strings.TrimSpace(s)) if err != nil { return 0 } return d } // firstEnv returns the first non-empty env var among keys, else def. func firstEnv(def string, keys ...string) string { for _, k := range keys { if v := os.Getenv(k); v != "" { return v } } return def } // Load reads configuration from the environment, applying sane local defaults // so `go run ./cmd/server` works without docker-compose. func Load() Config { return Config{ Port: env("PORT", "8080"), DatabaseURL: databaseURL(), S3Endpoint: withScheme(env("S3_ENDPOINT", "http://localhost:9000")), S3PublicEndpoint: publicEndpoint(), S3Region: env("S3_REGION", "us-east-1"), S3Bucket: env("S3_BUCKET", "spin"), S3Prefix: env("S3_PREFIX", ""), // Cluster secrets arrive as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY; // the S3_* aliases are kept for convenience/back-compat. S3AccessKey: firstEnv("minioadmin", "AWS_ACCESS_KEY_ID", "S3_ACCESS_KEY"), S3SecretKey: firstEnv("minioadmin", "AWS_SECRET_ACCESS_KEY", "S3_SECRET_KEY"), DevAuth: env("DEV_AUTH", "true") != "false", // Sample data is seeded ONLY when SEED=true is explicitly set. Default is // OFF so production never seeds (avoids confusion); local docker-compose / // `make be-dev` opt in with SEED=true. SeedData: env("SEED", "false") == "true", // Super-admin Keycloak groups (comma-separated). Default: admin // (shared group name across all internal apps, not app-specific). AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""), MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")), OpenAIKey: env("OPENAI_API_KEY", ""), LogoutURL: env("LOGOUT_URL", "/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"), } } // splitCSV splits a comma-separated list, trimming whitespace and dropping // empty entries. func splitCSV(raw string) []string { var out []string for _, p := range strings.Split(raw, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // databaseURL prefers an explicit DATABASE_URL (override), otherwise assembles a // DSN from the discrete PG* variables injected from the DB-creds Secret. func databaseURL() string { if v := os.Getenv("DATABASE_URL"); v != "" { return v } u := url.URL{ Scheme: "postgres", User: url.UserPassword(env("PGUSER", "spin"), env("PGPASSWORD", "spin")), Host: env("PGHOST", "localhost") + ":" + env("PGPORT", "5432"), Path: "/" + env("PGDATABASE", "spin"), } q := url.Values{} q.Set("sslmode", env("PGSSLMODE", "disable")) u.RawQuery = q.Encode() return u.String() } // publicEndpoint is the browser-reachable S3 host used for presigned URLs. // Falls back to the in-cluster endpoint when not separately provided. func publicEndpoint() string { if v := os.Getenv("S3_PUBLIC_ENDPOINT"); v != "" { return withScheme(v) } return withScheme(env("S3_ENDPOINT", "http://localhost:9000")) } // withScheme guarantees an http(s):// prefix so the AWS SDK accepts the endpoint // (the .env.sample shows bare hosts like "localhost"). func withScheme(s string) string { if s == "" || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { return s } return "http://" + s }