package config import ( "fmt" "log/slog" "os" "strings" "sync" "github.com/joho/godotenv" ) type Environment string const ( Dev Environment = "dev" Prod Environment = "prod" ) type Config struct { Environment Environment Host string Port string LogLevel slog.Level // SessionSecret is the HMAC key for gorilla cookie sessions. Required // in prod; defaulted in dev so getting started is friction-free. SessionSecret string // PublicURL is the externally-visible origin of this server, used to // derive OAuth client_id, redirect URIs, and cookie security defaults. // Examples: "http://127.0.0.1:8080" (dev), "https://atmo.quest" (prod). PublicURL string // DatabaseURL is the SQLite DSN for the local app DB (admin flags, event // cache, OAuth sessions, etc.). Example: // file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON) DatabaseURL string // OAuthPrivateKeyPath is the on-disk location of the ES256 (P-256) private // key used to sign client_assertion JWTs when running as a confidential // OAuth client. Auto-generated on first run if the file is missing. // Ignored when IsLocalhost() is true (loopback dev uses a public client). OAuthPrivateKeyPath string // TapWSEndpoint is the WebSocket URL of the self-hosted Bluesky Tap // service, in collection-signal mode for quest.atmo.connection. When empty // (the default, incl. dev/tests) the Tap consumer is disabled and // reciprocity falls back to the login-drain queue. Example: // ws://127.0.0.1:9000/subscribe TapWSEndpoint string // TapAuthToken is an optional bearer token sent when connecting to Tap. // Leave empty if Tap is unauthenticated (e.g. on a private network). TapAuthToken string } var ( Global *Config once sync.Once ) func init() { once.Do(func() { Global = Load() }) } func getEnv(key, fallback string) string { if val, ok := os.LookupEnv(key); ok { return val } return fallback } func loadBase() *Config { godotenv.Load() host := getEnv("HOST", "0.0.0.0") port := getEnv("PORT", "8080") defaultPublicURL := fmt.Sprintf("http://127.0.0.1:%s", port) return &Config{ Host: host, Port: port, LogLevel: func() slog.Level { switch os.Getenv("LOG_LEVEL") { case "DEBUG": return slog.LevelDebug case "INFO": return slog.LevelInfo case "WARN": return slog.LevelWarn case "ERROR": return slog.LevelError default: return slog.LevelInfo } }(), SessionSecret: getEnv("SESSION_SECRET", "session-secret"), PublicURL: getEnv("PUBLIC_URL", defaultPublicURL), DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"), OAuthPrivateKeyPath: getEnv("OAUTH_PRIVATE_KEY_PATH", "data/oauth_key.pem"), TapWSEndpoint: getEnv("TAP_WS_ENDPOINT", ""), TapAuthToken: getEnv("TAP_AUTH_TOKEN", ""), } } // Validate enforces invariants that aren't safe to enforce in init(). Called // from main() so misconfiguration surfaces as a clean fatal error with a // message, not a mid-request panic. func (c *Config) Validate() error { if c.DatabaseURL == "" { return fmt.Errorf("DATABASE_URL is required") } if c.PublicURL == "" { return fmt.Errorf("PUBLIC_URL is required") } if c.Environment == Prod { if c.SessionSecret == "" || c.SessionSecret == "session-secret" { return fmt.Errorf("SESSION_SECRET must be set to a real secret in production") } } return nil } // IsLocalhost reports whether PublicURL is a loopback origin. When true the // OAuth client runs in loopback/public mode (no client_assertion, no signing // key) because Authorization Servers reject http:// client_ids elsewhere. func (c *Config) IsLocalhost() bool { u := c.PublicURL return strings.HasPrefix(u, "http://localhost") || strings.HasPrefix(u, "http://127.0.0.1") } // IsSecure reports whether PublicURL is https — used to decide cookie Secure flag. func (c *Config) IsSecure() bool { return strings.HasPrefix(c.PublicURL, "https://") }