package config import ( "fmt" "os" "strconv" "time" ) type Config struct { Env string HTTPAddr string DatabaseURL string NATSURL string RedisAddr string FraudGRPCAddr string FraudGRPCTimeout time.Duration ShutdownTimeout time.Duration TokenSecret string TokenTTL time.Duration // Auth JWTSecret string JWTIssuer string AccessTokenTTL time.Duration RefreshTokenTTL time.Duration EmailVerificationTTL time.Duration PasswordResetTTL time.Duration PublicBaseURL string RefreshCookieDomain string RefreshCookieSecure bool // Notifications (Block D). Empty values leave the log-stub adapter in // place so the service still boots without AWS / Twilio creds. SESRegion string SESFromEmail string SESFromName string SESConfigurationSet string SMTPHost string SMTPPort int SMTPUsername string SMTPPassword string SMTPFromEmail string SMTPFromName string SMTPTLS string // "starttls" | "implicit" | "none" (default starttls) ResendAPIKey string ResendFromEmail string ResendFromName string TwilioAccountSID string TwilioAuthToken string TwilioFromNumber string // Billing (Block F). Empty StripeSecretKey leaves billing disabled — // all users get free-tier limits, /billing/* returns 503. Lets the // service boot in dev without Stripe creds. StripeSecretKey string StripeWebhookSecret string StripePricePro string // Stripe Price ID for Pro monthly StripePriceBusiness string // Stripe Price ID for Business monthly UnsubscribeSecret string // HMAC key for signing unsubscribe links // Uploads (Tier 2 Block D). LocalFSStore writes to UploadsDir and // serves via the API's /uploads/ route; in production this is // a deployment concern (S3 bucket + CDN URL). UploadsDir string UploadsPublicURL string } func Load() (*Config, error) { cfg := &Config{ Env: getenv("GG_ENV", "development"), HTTPAddr: getenv("GG_HTTP_ADDR", ":8080"), DatabaseURL: getenv("GG_DATABASE_URL", "postgres://guestguard:guestguard@localhost:5432/guestguard?sslmode=disable"), NATSURL: getenv("GG_NATS_URL", "nats://localhost:4222"), RedisAddr: getenv("GG_REDIS_ADDR", "localhost:6379"), FraudGRPCAddr: getenv("GG_FRAUD_GRPC_ADDR", "fraud-engine:9091"), FraudGRPCTimeout: getenvDuration("GG_FRAUD_GRPC_TIMEOUT", 250*time.Millisecond), ShutdownTimeout: getenvDuration("GG_SHUTDOWN_TIMEOUT", 15*time.Second), TokenSecret: os.Getenv("GG_TOKEN_SECRET"), TokenTTL: getenvDuration("GG_TOKEN_TTL", 30*24*time.Hour), JWTSecret: os.Getenv("GG_JWT_SECRET"), JWTIssuer: getenv("GG_JWT_ISSUER", "guestguard"), AccessTokenTTL: getenvDuration("GG_ACCESS_TOKEN_TTL", 15*time.Minute), RefreshTokenTTL: getenvDuration("GG_REFRESH_TOKEN_TTL", 30*24*time.Hour), EmailVerificationTTL: getenvDuration("GG_EMAIL_VERIFICATION_TTL", 24*time.Hour), PasswordResetTTL: getenvDuration("GG_PASSWORD_RESET_TTL", 1*time.Hour), PublicBaseURL: getenv("GG_PUBLIC_BASE_URL", "http://localhost:3000"), RefreshCookieDomain: os.Getenv("GG_REFRESH_COOKIE_DOMAIN"), RefreshCookieSecure: getenvBool("GG_REFRESH_COOKIE_SECURE", false), SESRegion: getenv("GG_SES_REGION", "us-east-1"), SESFromEmail: os.Getenv("GG_SES_FROM_EMAIL"), SESFromName: getenv("GG_SES_FROM_NAME", "GuestGuard"), SESConfigurationSet: os.Getenv("GG_SES_CONFIGURATION_SET"), SMTPHost: os.Getenv("GG_SMTP_HOST"), SMTPPort: getenvInt("GG_SMTP_PORT", 587), SMTPUsername: os.Getenv("GG_SMTP_USERNAME"), SMTPPassword: os.Getenv("GG_SMTP_PASSWORD"), SMTPFromEmail: os.Getenv("GG_SMTP_FROM_EMAIL"), SMTPFromName: getenv("GG_SMTP_FROM_NAME", "GuestGuard"), SMTPTLS: getenv("GG_SMTP_TLS", "starttls"), ResendAPIKey: os.Getenv("GG_RESEND_API_KEY"), ResendFromEmail: os.Getenv("GG_RESEND_FROM_EMAIL"), ResendFromName: getenv("GG_RESEND_FROM_NAME", "GuestGuard"), TwilioAccountSID: os.Getenv("GG_TWILIO_ACCOUNT_SID"), TwilioAuthToken: os.Getenv("GG_TWILIO_AUTH_TOKEN"), TwilioFromNumber: os.Getenv("GG_TWILIO_FROM_NUMBER"), StripeSecretKey: os.Getenv("GG_STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("GG_STRIPE_WEBHOOK_SECRET"), StripePricePro: os.Getenv("GG_STRIPE_PRICE_PRO"), StripePriceBusiness: os.Getenv("GG_STRIPE_PRICE_BUSINESS"), UnsubscribeSecret: os.Getenv("GG_UNSUBSCRIBE_SECRET"), UploadsDir: getenv("GG_UPLOADS_DIR", "/var/lib/guestguard/uploads"), UploadsPublicURL: getenv("GG_UPLOADS_PUBLIC_URL", "http://localhost:8080/uploads"), } if cfg.Env == "production" && cfg.TokenSecret == "" { return nil, fmt.Errorf("GG_TOKEN_SECRET is required in production") } if cfg.TokenSecret == "" { cfg.TokenSecret = "dev-only-insecure-secret-change-me" } if cfg.Env == "production" && cfg.JWTSecret == "" { return nil, fmt.Errorf("GG_JWT_SECRET is required in production") } if cfg.JWTSecret == "" { cfg.JWTSecret = "dev-only-insecure-jwt-secret-change-me-32+bytes" } if len(cfg.JWTSecret) < 32 { return nil, fmt.Errorf("GG_JWT_SECRET must be at least 32 bytes") } if cfg.UnsubscribeSecret == "" { // Same dev fallback shape as the other secrets — production refuses // to boot without it. if cfg.Env == "production" { return nil, fmt.Errorf("GG_UNSUBSCRIBE_SECRET is required in production") } cfg.UnsubscribeSecret = "dev-only-insecure-unsubscribe-secret-change-me" } return cfg, nil } func getenvInt(key string, fallback int) int { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func getenvBool(key string, fallback bool) bool { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } func getenv(key, fallback string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return fallback } func getenvDuration(key string, fallback time.Duration) time.Duration { v, ok := os.LookupEnv(key) if !ok || v == "" { return fallback } if d, err := time.ParseDuration(v); err == nil { return d } if secs, err := strconv.Atoi(v); err == nil { return time.Duration(secs) * time.Second } return fallback }