Files
krow_backend/go-api/internal/config/config.go
2026-08-25 16:37:05 +05:30

383 lines
13 KiB
Go

// 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://<unparseable>"
}
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`.
// 5174 is where Vite lands when 5173 is already taken, which happens whenever a
// second dev server is started; an origin missing from this list is refused at
// the preflight with a bare 403 and no CORS headers, which reads as a server
// fault rather than a misconfigured port.
var devCORSOrigins = []string{
"http://localhost:5173", "http://127.0.0.1:5173",
"http://localhost:5174", "http://127.0.0.1:5174",
"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)
}
}
}