// Package config loads and validates the backend's runtime configuration. // // Configuration comes from the process environment. A .env file in the // repository root is read first as a convenience for local development, and // never overrides a variable that is already set — so an explicit // `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file. // // Nothing here has a credential baked in. Load fails loudly rather than // falling back to a default host, database or user, because a silent default // is how a development process ends up pointed at the wrong database. package config import ( "fmt" "net/url" "os" "strconv" "strings" "time" ) // Config is the whole of the Phase 1 configuration surface. type Config struct { AppEnv string Log LogConfig HTTP HTTPConfig DB DBConfig Seed SeedConfig } // SeedConfig locates the demo fixture. The file is generated from the frontend // repository, so it lives beside the migrations rather than inside the Go // module: regenerating it must not require rebuilding the binary. type SeedConfig struct { FixturePath string } type LogConfig struct { Level string } type HTTPConfig struct { Host string Port int ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration ShutdownTimeout time.Duration // CookieSameSite is the SameSite attribute on the session cookie: // "lax" (default), "none" or "strict". // // This exists because CORS is only half of what a cross-origin browser call // needs, and the other half is easy to miss. SameSite is judged on SITE // (registrable domain), not origin: // // app.krow.com → api.krow.com SAME site. Lax sends the cookie. ✓ // krow.vercel.app → api.krow.com CROSS site. Lax does NOT send it. ✗ // localhost:5173 → 127.0.0.1:8080 CROSS site — different hosts. ✗ // // So a deployment whose frontend is on an unrelated domain gets a perfect // set of CORS headers and still no session, because the browser never // attaches the cookie. "none" is the only value that survives that, and it // requires Secure, which means HTTPS. // // Default stays "lax": it is the safe value, it is correct for the // same-site and same-origin deployments this is normally run as, and it // gives CSRF protection that "none" gives up. CookieSameSite string // CORSOrigins is the exact set of browser origins allowed to call the API. // // It exists for one reason: in local development the Vite dev server is an // origin of its own (http://localhost:5173) and the API is another // (http://127.0.0.1:8080), so every fetch from the frontend is // cross-origin. Empty means CORS is off and the API answers only // same-origin callers, which is the correct posture everywhere the // frontend is served from the same host as the API. // // Origins are matched exactly and echoed back one at a time. There is no // wildcard and no pattern: "*" would let any page on the internet read // this API, and once authentication exists that becomes a real hole rather // than a theoretical one. CORSOrigins []string } type DBConfig struct { Host string Port int Name string User string Password string Schema string SSLMode string MaxOpenConns int32 MinIdleConns int32 ConnMaxLifetime time.Duration ConnectTimeout time.Duration StatementTimeout time.Duration } // DSN builds a libpq-style connection URL. // // Every component is URL-escaped: the local database is called "Krow-force", // which is both mixed-case and hyphenated, and a password may contain anything // at all. Escaping is not optional here. func (d DBConfig) DSN() string { u := &url.URL{ Scheme: "postgres", User: url.UserPassword(d.User, d.Password), Host: fmt.Sprintf("%s:%d", d.Host, d.Port), Path: "/" + d.Name, } q := u.Query() q.Set("sslmode", d.SSLMode) // Pin the schema on every connection so no query can accidentally resolve // against a different one, and so nothing reaches for a system schema. q.Set("search_path", d.Schema) q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds()))) q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds()))) u.RawQuery = q.Encode() return u.String() } // Redacted returns the DSN with the password replaced, for logs. func (d DBConfig) Redacted() string { u, err := url.Parse(d.DSN()) if err != nil { return "postgres://" } if _, hasPassword := u.User.Password(); hasPassword { u.User = url.UserPassword(u.User.Username(), "xxxxx") } return u.String() } // Load reads the environment, applies defaults and validates the result. func Load() (*Config, error) { loadDotEnv(".env") var missing []string required := func(key string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { missing = append(missing, key) } return v } cfg := &Config{ AppEnv: withDefault("APP_ENV", "development"), Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")}, HTTP: HTTPConfig{ Host: withDefault("HTTP_HOST", "127.0.0.1"), Port: intDefault("HTTP_PORT", 8080), ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second), WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 30*time.Second), IdleTimeout: durationDefault("HTTP_IDLE_TIMEOUT", 60*time.Second), ShutdownTimeout: durationDefault("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second), CORSOrigins: corsOrigins(withDefault("APP_ENV", "development")), CookieSameSite: strings.ToLower(withDefault("HTTP_COOKIE_SAMESITE", "lax")), }, Seed: SeedConfig{ FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"), }, DB: DBConfig{ Host: required("DATABASE_HOST"), Port: intDefault("DATABASE_PORT", 5432), Name: required("DATABASE_NAME"), User: required("DATABASE_USER"), Password: os.Getenv("DATABASE_PASSWORD"), // may legitimately be empty (trust/peer auth) Schema: withDefault("DATABASE_SCHEMA", "public"), SSLMode: withDefault("DATABASE_SSLMODE", "disable"), MaxOpenConns: int32(intDefault("DATABASE_MAX_OPEN_CONNS", 25)), MinIdleConns: int32(intDefault("DATABASE_MIN_IDLE_CONNS", 2)), ConnMaxLifetime: durationDefault("DATABASE_CONN_MAX_LIFETIME", 30*time.Minute), ConnectTimeout: durationDefault("DATABASE_CONNECT_TIMEOUT", 5*time.Second), StatementTimeout: durationDefault("DATABASE_STATEMENT_TIMEOUT", 10*time.Second), }, } if len(missing) > 0 { return nil, fmt.Errorf("missing required environment variables: %s "+ "(copy .env.example to .env and fill them in)", strings.Join(missing, ", ")) } if err := cfg.validate(); err != nil { return nil, err } return cfg, nil } func (c *Config) validate() error { switch c.AppEnv { case "development", "staging", "production": default: return fmt.Errorf("APP_ENV must be development, staging or production, got %q", c.AppEnv) } if c.HTTP.Port < 1 || c.HTTP.Port > 65535 { return fmt.Errorf("HTTP_PORT out of range: %d", c.HTTP.Port) } if c.DB.Port < 1 || c.DB.Port > 65535 { return fmt.Errorf("DATABASE_PORT out of range: %d", c.DB.Port) } // The application owns exactly one schema and it is never a system schema. switch c.DB.Schema { case "pg_catalog", "pg_toast", "information_schema": return fmt.Errorf("DATABASE_SCHEMA must not be a PostgreSQL system schema, got %q", c.DB.Schema) } if strings.HasPrefix(c.DB.Schema, "pg_") { return fmt.Errorf("DATABASE_SCHEMA must not start with \"pg_\", got %q", c.DB.Schema) } if c.DB.MinIdleConns > c.DB.MaxOpenConns { return fmt.Errorf("DATABASE_MIN_IDLE_CONNS (%d) exceeds DATABASE_MAX_OPEN_CONNS (%d)", c.DB.MinIdleConns, c.DB.MaxOpenConns) } if c.AppEnv == "production" && c.DB.SSLMode == "disable" { return fmt.Errorf("DATABASE_SSLMODE=disable is not allowed when APP_ENV=production") } switch c.HTTP.CookieSameSite { case "lax", "strict": case "none": // SameSite=None without Secure is ignored — and in current browsers, // rejected outright — so the cookie would simply never be stored. The // Secure flag is set for every APP_ENV except development, so this is // the one combination that produces a silently sessionless deployment. if c.AppEnv == "development" { return fmt.Errorf("HTTP_COOKIE_SAMESITE=none requires the Secure cookie flag, " + "which is not set when APP_ENV=development; SameSite=None over plain HTTP " + "is rejected by browsers") } default: return fmt.Errorf("HTTP_COOKIE_SAMESITE must be lax, none or strict, got %q", c.HTTP.CookieSameSite) } for _, origin := range c.HTTP.CORSOrigins { // "*" is rejected rather than quietly honoured. The middleware echoes a // single matched origin, so a wildcard could only ever be a // misunderstanding of what this setting does. // "*" is not a stricter-than-necessary policy choice — it cannot work // here at all. Authentication is a cookie, so the API must answer // Access-Control-Allow-Credentials: true, and every browser REFUSES // the pairing of that header with Allow-Origin: "*". A deployment // configured this way would send correct-looking headers and have // every authenticated call blocked client-side. if origin == "*" { return fmt.Errorf(`HTTP_CORS_ORIGINS must list explicit origins; "*" cannot be used ` + `because this API authenticates with a cookie, and browsers reject ` + `Access-Control-Allow-Origin: "*" together with credentials. ` + `List each frontend origin, or serve the frontend from the API's own origin ` + `(then leave this unset and CORS is not involved at all)`) } if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { return fmt.Errorf("HTTP_CORS_ORIGINS entry %q must be a full origin including the scheme", origin) } } return nil } // devCORSOrigins are the origins the Vite dev server can occupy. Vite binds // localhost by default and 127.0.0.1 when asked, and a browser treats those two // as different origins, so both are listed. 4173 is `vite preview`. var devCORSOrigins = []string{ "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:4173", "http://127.0.0.1:4173", } // corsOrigins reads HTTP_CORS_ORIGINS, a comma-separated allowlist. // // The development default is the Vite dev server, because that is the whole // point of the setting in Phase 2D. Outside development the default is empty: // a staging or production deployment that genuinely serves its frontend from // another origin has to say so explicitly, rather than inheriting a list of // localhost origins nobody reviewed. func corsOrigins(appEnv string) []string { raw, set := os.LookupEnv("HTTP_CORS_ORIGINS") if !set { if appEnv == "development" { return devCORSOrigins } return nil } var out []string for _, part := range strings.Split(raw, ",") { // A trailing slash makes the string unequal to the Origin header the // browser actually sends, which fails in a way that looks like a // server bug rather than a typo. if o := strings.TrimRight(strings.TrimSpace(part), "/"); o != "" { out = append(out, o) } } return out } func withDefault(key, fallback string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return fallback } func intDefault(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } func durationDefault(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } d, err := time.ParseDuration(v) if err != nil { return fallback } return d } // loadDotEnv reads KEY=VALUE lines, walking up from the working directory so // `go run ./cmd/api` finds the repository-root .env. Existing environment // variables always win. Absence of the file is not an error. func loadDotEnv(name string) { dir, err := os.Getwd() if err != nil { return } for i := 0; i < 5; i++ { path := dir + string(os.PathSeparator) + name if data, err := os.ReadFile(path); err == nil { applyDotEnv(string(data)) return } parent := parentDir(dir) if parent == dir { return } dir = parent } } func parentDir(dir string) string { i := strings.LastIndex(dir, string(os.PathSeparator)) if i <= 0 { return dir } return dir[:i] } func applyDotEnv(content string) { for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(strings.TrimPrefix(key, "export ")) value = strings.TrimSpace(value) if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = value[1 : len(value)-1] } } if _, present := os.LookupEnv(key); !present { _ = os.Setenv(key, value) } } }