Files
krow_backend/go-api/internal/config/config.go
2026-08-28 12:21:44 +05:30

539 lines
20 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"
)
// defaultModel is what every reasoning tier routes to until a deployment says
// otherwise. Named once here so the three tiers cannot drift apart by accident.
const defaultModel = "claude-opus-5"
// Config is the whole of the Phase 1 configuration surface.
type Config struct {
AppEnv string
Log LogConfig
HTTP HTTPConfig
DB DBConfig
Seed SeedConfig
Model ModelConfig
Knowledge KnowledgeConfig
}
// KnowledgeConfig routes the retrieval layer's embedding provider.
//
// Anthropic does not serve embeddings, so the dense half of hybrid retrieval
// needs a separate credential. Voyage is the documented partner and the default.
//
// An empty key is legitimate: this service boots and serves without one, and
// retrieval degrades to keyword-only rather than failing — reported on every
// result, never silently. What is NOT legitimate is production running on the
// lexical stand-in, which is why that is a separate, deliberate opt-in rather
// than something an empty key falls back to.
type KnowledgeConfig struct {
// EmbedProvider names which embedder to use: "voyage", "ollama",
// "lexical", or "" to pick from what is configured.
//
// Explicit beats inferred here. The three differ in a way that is invisible
// from the outside — all of them return vectors and retrieval works with
// any of them — so a deployment silently running the stand-in would look
// exactly like one running a real model, right up until somebody phrased a
// question differently. Naming the provider makes the choice reviewable.
EmbedProvider string
// EmbedAPIKey is the hosted provider's credential (Voyage).
EmbedAPIKey string
// EmbedBaseURL is where a local model answers. Ollama's default is
// http://localhost:11434.
EmbedBaseURL string
EmbedModel string
EmbedDims int
// UseLexicalEmbedder swaps in the deterministic stand-in. Development only:
// it is not semantic, and a corpus indexed with it retrieves on word overlap
// alone. Load() refuses it outside development rather than trusting the
// operator to have read the comment.
//
// Kept alongside EmbedProvider for the deployments that already set it.
UseLexicalEmbedder bool
}
// ModelConfig routes an agent spec's reasoning tier to a model.
//
// A spec declares `reasoning: fast | balanced | deep`, never a model id, so the
// mapping is a deployment decision and changes without editing a definition.
// All three default to the same model: the tiers differ by *effort*, which the
// gateway owns, and a deployment that wants a cheaper model on the fast tier
// says so explicitly rather than inheriting a downgrade nobody chose.
//
// The API key may legitimately be empty outside production. This service has to
// boot without model credentials — migrations, seeding and every endpoint that
// is not an agent run work fine without one — so the failure belongs at the
// first model call, as a structured gateway.not_configured a run can end with,
// not at startup as a refusal to boot.
type ModelConfig struct {
APIKey string
Fast string
Balanced string
Deep string
MaxOutputTokens int
}
// 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")),
// Empty when unset, which is NOT the same as "lax": unset means "let
// the server derive it from the CORS posture", and an explicit value
// overrides that derivation. See Server.sessionSameSite.
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
},
Seed: SeedConfig{
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
},
Knowledge: KnowledgeConfig{
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
EmbedBaseURL: strings.TrimSpace(os.Getenv("EMBED_BASE_URL")),
// No default model or width here: they differ per provider, and one
// shared default would silently hand Ollama's dimensions to Voyage.
// Resolved where the provider is chosen — see runtime.NewEmbedder.
EmbedModel: strings.TrimSpace(os.Getenv("EMBED_MODEL")),
EmbedDims: intDefault("EMBED_DIMENSIONS", 0),
UseLexicalEmbedder: boolDefault("EMBED_USE_LEXICAL", false),
},
Model: ModelConfig{
APIKey: strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")),
Fast: withDefault("MODEL_FAST", defaultModel),
Balanced: withDefault("MODEL_BALANCED", defaultModel),
Deep: withDefault("MODEL_DEEP", defaultModel),
// 16k keeps a non-streaming response inside the SDK's HTTP
// timeout. The loop raises it and switches to streaming when it
// needs a long answer; this is the ceiling for a single
// unstreamed call, not the run's budget.
MaxOutputTokens: intDefault("MODEL_MAX_OUTPUT_TOKENS", 16000),
},
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")
}
// A production deployment with no model credentials would accept agent runs
// and fail every one of them at the gateway. That is a boot-time
// misconfiguration wearing a runtime error's clothes, so it is caught here.
// Development is left alone deliberately: working on migrations or the
// definitions API must not require a key.
if c.AppEnv == "production" && c.Model.APIKey == "" {
return fmt.Errorf("ANTHROPIC_API_KEY is required when APP_ENV=production; " +
"without it every agent run fails at the model gateway")
}
if c.Model.MaxOutputTokens < 1 {
return fmt.Errorf("MODEL_MAX_OUTPUT_TOKENS must be at least 1, got %d", c.Model.MaxOutputTokens)
}
// The lexical embedder is a development stand-in that hashes words into a
// vector. It is not semantic, so a production corpus indexed with it would
// retrieve on word overlap alone — which looks like working retrieval and is
// not. Refused here rather than trusted to an operator's reading of a
// comment, because the failure is invisible from the outside: results come
// back, they are just the wrong ones.
switch c.Knowledge.EmbedProvider {
case "", "voyage", "ollama", "lexical":
default:
return fmt.Errorf("EMBED_PROVIDER must be voyage, ollama or lexical, got %q",
c.Knowledge.EmbedProvider)
}
if c.AppEnv == "production" &&
(c.Knowledge.UseLexicalEmbedder || c.Knowledge.EmbedProvider == "lexical") {
return fmt.Errorf("EMBED_USE_LEXICAL is a development stand-in and is not allowed when " +
"APP_ENV=production; it is not a semantic embedder and a corpus indexed with it " +
"retrieves on word overlap alone")
}
// Zero means "the provider's own default", resolved where the provider is
// chosen. Only a negative value is a mistake.
if c.Knowledge.EmbedDims < 0 {
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
}
for name, model := range map[string]string{
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
} {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("%s must name a model", name)
}
}
switch c.HTTP.CookieSameSite {
// Unset. The server derives the mode from whether a CORS allowlist is
// configured; there is nothing to validate.
case "":
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
}
// boolDefault reads a boolean flag.
//
// An unparseable value falls back rather than erroring, matching intDefault.
// The one asymmetry worth knowing: only the explicit true spellings turn a flag
// on, so a typo'd "yes" leaves a feature off rather than on — the safe
// direction for every flag this file currently carries.
func boolDefault(key string, fallback bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return fallback
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
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)
}
}
}