// 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 { // Provider names the wire protocol: "anthropic" or "openai". Empty means // anthropic, so a deployment that predates the second provider keeps // working with the environment it already has. // // "openai" is not only OpenAI. Groq, Gemini's compatibility endpoint, // OpenRouter, Together, vLLM and a local Ollama all serve that same shape, // and BaseURL is what chooses between them. Provider string APIKey string // BaseURL points the OpenAI-compatible provider at a specific service. // Ignored by the anthropic provider, which has one endpoint. BaseURL string Fast string Balanced string Deep string MaxOutputTokens int // ReasoningEffort opts into sending the tier's effort level on the // OpenAI-compatible wire. Off by default: reasoning models accept the // field and most others reject the entire request rather than ignoring it. ReasoningEffort bool } // 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", DeepestAgentDeadline+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{ Provider: strings.ToLower(strings.TrimSpace(os.Getenv("MODEL_PROVIDER"))), // MODEL_API_KEY first, then the Anthropic-specific name. Two // spellings because the second provider is not Anthropic and // ANTHROPIC_API_KEY= would be a lie an operator has to // keep re-reading; the fallback keeps every existing deployment // working without an edit. APIKey: firstSet("MODEL_API_KEY", "ANTHROPIC_API_KEY"), BaseURL: strings.TrimSpace(os.Getenv("MODEL_BASE_URL")), 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), ReasoningEffort: boolDefault("MODEL_REASONING_EFFORT", false), }, 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 } // DeepestAgentDeadline is the longest a single agent run may take — the // `deep` tier's deadline in runtime.LimitsForTier. // // Duplicated rather than imported because internal/runtime already imports // this package, and a cycle to share one number is a bad trade. A test in // internal/runtime asserts the two agree, so this drifting is a build failure // rather than a discovery. const DeepestAgentDeadline = 120 * time.Second // validateWriteTimeout refuses a server that would cut off a run the runtime // considers legal. // // HTTP_WRITE_TIMEOUT was 30s in production while every shipped agent runs at // the `balanced` tier, whose deadline is 60s. The server therefore aborted the // response on any run over half its allowed time, and the caller saw 502 Bad // Gateway from the proxy in front — a gateway error for something no gateway // did, which is why it read as an infrastructure fault for so long. // // Delegation made it routine rather than causing it: a parent that asks two // subagents spends longer than one that answers alone. The misconfiguration // predates it. // // Streaming hides it, and that is the trap. The chat panel uses SSE and // survives, so the product looks healthy while every non-streaming caller — a // webhook, a script, an integration — gets 502 on a slow question. // validateModel refuses a model configuration that cannot work. // // Its own method for the same reason validateWriteTimeout is: these are the // mistakes that produce a *runtime* symptom far from their cause — a deployment // that believes it switched providers and is still being billed by the old one, // or a production install with no credential that fails one run at a time // instead of once at startup. func (c *Config) validateModel() error { switch c.Model.Provider { case "", "anthropic", "openai": default: return fmt.Errorf("MODEL_PROVIDER must be anthropic or openai, got %q", c.Model.Provider) } // A local model needs no credential, and demanding one would make the // zero-cost development path impossible to configure. Everything else does: // a production deployment without a key fails every run at the gateway, // which is a misconfiguration wearing a runtime error's clothes. if c.AppEnv == "production" && c.Model.APIKey == "" && !isLoopback(c.Model.BaseURL) { return fmt.Errorf("MODEL_API_KEY (or ANTHROPIC_API_KEY) is required when APP_ENV=production; " + "without it every agent run fails at the model gateway") } // A base URL is only read by the OpenAI-compatible provider. Setting one // while on anthropic is a deployment that believes it has switched // providers and has not — it would keep calling Claude and keep being // billed for it, with nothing in the logs to say so. if c.Model.BaseURL != "" && c.Model.Provider != "openai" { return fmt.Errorf("MODEL_BASE_URL only applies when MODEL_PROVIDER=openai; "+ "it is set to %q but the provider is %q, so the base URL would be ignored "+ "and every run would still go to Anthropic", c.Model.BaseURL, providerName(c.Model.Provider)) } if c.Model.BaseURL != "" { u, err := url.Parse(c.Model.BaseURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return fmt.Errorf("MODEL_BASE_URL must be an http or https URL, got %q", c.Model.BaseURL) } } return nil } func (c *Config) validateWriteTimeout() error { if c.HTTP.WriteTimeout <= 0 { return nil // no deadline set; the server will not cut anything off } if c.HTTP.WriteTimeout < DeepestAgentDeadline { return fmt.Errorf( "HTTP_WRITE_TIMEOUT is %s but an agent run may take %s (the deep tier's "+ "deadline); the server would abort the response while the run is still "+ "legal, and the caller would see 502 from the proxy. Set it above %s", c.HTTP.WriteTimeout, DeepestAgentDeadline, DeepestAgentDeadline) } return nil } func (c *Config) validate() error { if err := c.validateWriteTimeout(); err != nil { return err } 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 err := c.validateModel(); err != nil { return err } 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 } // firstSet returns the first of several environment variables that has a value. // // For settings that have more than one legitimate spelling — a generic name and // a provider-specific one — where the order expresses which wins rather than // leaving it to whichever happens to be read last. func firstSet(keys ...string) string { for _, k := range keys { if v := strings.TrimSpace(os.Getenv(k)); v != "" { return v } } return "" } // isLoopback reports whether a base URL points at this machine. // // A model served from localhost needs no credential, and requiring one would // make the zero-cost local path impossible to configure. Host-only, so a // remote service that merely mentions "localhost" in a path does not qualify. func isLoopback(raw string) bool { if strings.TrimSpace(raw) == "" { return false } u, err := url.Parse(raw) if err != nil { return false } host := u.Hostname() return host == "localhost" || host == "127.0.0.1" || host == "::1" } // providerName renders the provider for an error message, naming the default // rather than showing an empty string an operator then has to interpret. func providerName(p string) string { if p == "" { return "anthropic (the default)" } return p } 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) } } }