Files
krow_backend/go-api/internal/config/config.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

946 lines
39 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/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// The default model per tier, and the endpoint they are valid on.
//
// THESE THREE AND defaultBaseURL ARE ONE DECISION, not four. A model id is only
// meaningful against the service that serves it, so a Groq id with an OpenAI
// base URL is not a partial configuration — it is a broken one that starts
// cleanly and fails every run at request time. They changed together when the
// Anthropic path was removed and they have to keep changing together.
//
// Unlike the old single default, the tiers are no longer the same model: the
// point of a tier is that `fast` costs less than `deep`, and one id for all
// three made the distinction free and therefore meaningless.
const (
defaultBaseURL = "https://api.groq.com/openai/v1"
defaultFastModel = "openai/gpt-oss-20b"
defaultBalancedModel = "openai/gpt-oss-120b"
defaultDeepModel = "openai/gpt-oss-120b"
)
// DefaultModels returns the model ids a deployment gets when MODEL_FAST,
// MODEL_BALANCED and MODEL_DEEP are all unset.
//
// Exported so the live suite can ask the provider whether it still serves them.
// It reads these rather than repeating the list because a second copy is the
// first thing that drifts, and drift is the exact failure that check defends
// against: these ids are retired on the provider's schedule, not this repo's.
func DefaultModels() (fast, balanced, deep string) {
return defaultFastModel, defaultBalancedModel, defaultDeepModel
}
// Config is the whole of the Phase 1 configuration surface.
type Config struct {
AppEnv string
Log LogConfig
HTTP HTTPConfig
DB DBConfig
Seed SeedConfig
Agents AgentsConfig
Model ModelConfig
Knowledge KnowledgeConfig
OAuth OAuthConfig
}
// OAuthConfig is the MCP surface's OAuth 2.1 identity.
//
// EMPTY IS THE DEFAULT AND IT MEANS "OFF". A deployment that sets neither
// OAUTH_ISSUER nor MCP_RESOURCE does not serve OAuth or MCP at all, and that is
// the correct default for every deployment that exists today — the routes are
// simply not registered, exactly as routeRuns is skipped without a model
// credential.
//
// NO PRODUCTION DOMAIN IS HARDCODED. Both values are URLs the operator supplies,
// because the issuer identifies the deployment and a default would be one
// deployment's identity baked into every other one.
//
// Issuer and Resource look similar and are not the same thing: the ISSUER
// identifies the authorization server ("who minted this token"), the RESOURCE
// identifies what the token is good for ("which MCP server may spend it"). A
// token's audience is checked against Resource. Conflating them is how a token
// for one service becomes spendable at another.
type OAuthConfig struct {
// Issuer is the authorization server's base URL, e.g.
// https://api.example.com. No trailing slash.
Issuer string
// Resource is the canonical MCP endpoint URI, e.g.
// https://api.example.com/mcp. This becomes an issued token's audience.
Resource string
// LoginPath is where the authorization endpoint sends somebody who is not
// signed in. A same-origin path, never an absolute URL — an absolute one
// would be an open redirect waiting for a misconfiguration.
LoginPath string
}
// Enabled reports whether this deployment serves OAuth and MCP.
func (c OAuthConfig) Enabled() bool { return c.Issuer != "" && c.Resource != "" }
// KnowledgeConfig routes the retrieval layer's embedding provider.
//
// The chat provider does not serve embeddings, so the dense half of hybrid
// retrieval needs its own provider and credential — this is a separate choice
// from MODEL_*, and pointing one of them somewhere new does not move the other.
//
// 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. "openai" is the only one, and empty
// means it; "anthropic" is refused at startup rather than ignored, because
// a deployment still carrying it has not been told the path was removed.
//
// "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 — which is why one wire protocol
// is not the same thing as one vendor.
Provider string
APIKey string
// BaseURL points the provider at a specific service. Empty means the
// default in defaultBaseURL, which the default model ids belong to.
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
}
// AgentsConfig locates the curated agent specs that ship with the deployment.
//
// The same directory `importagents` publishes from — Dockerfile.api copies
// `agents/` to /app/agents beside the binary, and the importer's own `-dir`
// default is the same path. Pointing both at one directory is what makes the
// protected set and the published set the same set: an agent is built-in
// because the product ships its spec, not because a column says so.
//
// A missing directory is not a boot failure. This service runs in development
// checkouts and test binaries whose working directory has no `agents/`, and
// refusing to start over a protection list would take the API down to defend
// rows that deployment never created. The consequence is stated where it is
// loaded: the protected set is empty, and that is logged.
type AgentsConfig struct {
CuratedPath 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
// TrustedProxies are the networks a forwarded client address may be
// believed from. Empty by default, and empty means "believe nothing".
//
// WHY THIS EXISTS
//
// Several limits on this API are keyed by the caller's network address:
// failed logins, OAuth registration, and OAuth authorization before the
// caller has signed in. Behind a reverse proxy every request arrives from
// the proxy, so RemoteAddr is one constant value and those per-address
// budgets silently become one budget for the entire deployment. The
// symptom is users rate-limiting each other — one person retrying a
// connector exhausts everybody's allowance.
//
// WHY IT IS NOT SIMPLY "READ X-FORWARDED-FOR"
//
// That header is client-supplied. A caller reaching the API directly can
// invent one and mint a fresh budget per request, which is strictly worse
// than sharing a bucket: it removes the limit entirely. The header is
// meaningful only when the immediate peer is a proxy that is known to
// rewrite it, which is what this list names.
//
// WHY THE DEFAULT IS EMPTY
//
// So that a missing or misspelt setting cannot open the spoofing hole. An
// unconfigured deployment behaves exactly as it did before this setting
// existed: RemoteAddr, and X-Forwarded-For ignored. The failure mode of
// forgetting to set it is the old shared bucket, which is an availability
// problem an operator will notice, rather than an unmetered endpoint which
// they will not.
//
// Entries are CIDR blocks or bare addresses (a bare address is treated as
// a single-host block). Both families are accepted. Set it to the network
// the load balancer or ingress talks to the API from — see
// .env.example and infrastructure/.env.docker.example.
TrustedProxies []netip.Prefix
}
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
}
// Parsed before the literal below because it can fail, and a malformed
// entry has to stop startup rather than be dropped: an operator who
// mistypes the proxy network gets the shared-bucket behaviour back, and
// silently is the one way they will not find out.
trustedProxies, trustedProxiesErr := parseTrustedProxies(os.Getenv("HTTP_TRUSTED_PROXIES"))
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"))),
TrustedProxies: trustedProxies,
},
Seed: SeedConfig{
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
},
Agents: AgentsConfig{
CuratedPath: withDefault("CURATED_AGENTS_PATH", "./agents"),
},
OAuth: OAuthConfig{
// Trailing slashes trimmed here rather than at every use: the
// canonical form of a resource URI has none, and a token minted
// against ".../mcp/" would fail to validate against ".../mcp".
Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OAUTH_ISSUER")), "/"),
Resource: strings.TrimRight(strings.TrimSpace(os.Getenv("MCP_RESOURCE")), "/"),
LoginPath: withDefault("OAUTH_LOGIN_PATH", "/login"),
},
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"))),
// One spelling. ANTHROPIC_API_KEY used to be accepted as a
// fallback and is now deliberately NOT read: with the Anthropic
// path gone it would name a vendor this service cannot call, and
// silently authenticating to Groq with a variable called
// ANTHROPIC_API_KEY is the kind of lie an operator has to keep
// re-reading. A stale one is caught at startup, not ignored.
APIKey: strings.TrimSpace(os.Getenv("MODEL_API_KEY")),
BaseURL: withDefault("MODEL_BASE_URL", defaultBaseURL),
Fast: withDefault("MODEL_FAST", defaultFastModel),
Balanced: withDefault("MODEL_BALANCED", defaultBalancedModel),
Deep: withDefault("MODEL_DEEP", defaultDeepModel),
// 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 trustedProxiesErr != nil {
return nil, trustedProxiesErr
}
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 {
// "anthropic" is named separately from every other wrong value because it
// is the one that used to be correct. A deployment still carrying it is not
// a typo, it is a stack that has not been told the path was removed — and
// the silent alternative is a service that believes it is on Claude while
// every run goes to Groq and is billed there.
switch c.Model.Provider {
case "", "openai":
case "anthropic":
return fmt.Errorf("MODEL_PROVIDER=anthropic is no longer supported: the Anthropic " +
"path was removed and this service speaks only the openai chat-completions " +
"shape. Unset MODEL_PROVIDER (or set it to openai) and point MODEL_BASE_URL " +
"at your provider")
default:
return fmt.Errorf("MODEL_PROVIDER must be openai (or empty, which means openai), got %q", c.Model.Provider)
}
// A credential under the old name is refused rather than ignored. Ignoring
// it produces the worst version of this failure: a deployment that set a
// key, sees no error, and fails every run on a missing credential it is
// looking straight at.
if os.Getenv("ANTHROPIC_API_KEY") != "" && c.Model.APIKey == "" {
return fmt.Errorf("ANTHROPIC_API_KEY is set but is no longer read, and MODEL_API_KEY is " +
"empty: the Anthropic path was removed. Rename the variable to MODEL_API_KEY " +
"— and if that value is an Anthropic key, replace it, because nothing here can " +
"call Anthropic any more")
}
// 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 is required when APP_ENV=production; " +
"without it every agent run fails at the model gateway")
}
// A model id left over from the Anthropic path. THIS IS THE CHECK THAT
// REPLACED the old "base URL set against the wrong provider" one, and it
// guards the same failure from the other side.
//
// It is not hypothetical. A `claude-*` id sent to an OpenAI-compatible
// endpoint is accepted by this process, rejected by the provider, and
// surfaces as a 400 on EVERY run — which is exactly the incident that made
// the gateway start carrying upstream error text in the first place. One
// loud failure at startup is worth more than one per run.
for _, m := range []struct{ key, id string }{
{"MODEL_FAST", c.Model.Fast},
{"MODEL_BALANCED", c.Model.Balanced},
{"MODEL_DEEP", c.Model.Deep},
} {
if strings.HasPrefix(strings.ToLower(m.id), "claude") {
return fmt.Errorf("%s is %q, but the Anthropic path was removed: no configured "+
"provider serves a claude model, so every run on this tier would fail at "+
"the gateway. Set it to a model id your MODEL_BASE_URL (%s) serves",
m.key, m.id, c.Model.BaseURL)
}
}
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)
}
if err := c.validateOAuth(); err != nil {
return err
}
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
}
// parseTrustedProxies reads HTTP_TRUSTED_PROXIES, a comma-separated list of
// CIDR blocks or bare addresses.
//
// Unset or empty yields nil, which means no proxy is trusted and forwarded
// client addresses are ignored entirely. That is the safe default and the
// behaviour this API had before the setting existed.
//
// A bare address is accepted and widened to a single-host prefix, because
// "10.0.0.7" is what an operator reaches for when there is exactly one ingress
// and requiring them to write "10.0.0.7/32" only invites a mistake.
//
// Malformed entries are an error rather than a skip. Skipping one would leave
// the deployment quietly trusting a shorter list than the operator wrote, and
// the consequence — a proxy that is not believed, so every user shares one
// rate-limit bucket again — is precisely the fault this setting exists to fix.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
entry := strings.TrimSpace(part)
if entry == "" {
continue
}
if prefix, err := netip.ParsePrefix(entry); err == nil {
// Masked so that a block written with host bits set — 10.0.0.7/8,
// which is easy to write and easy to misread — still contains what
// its author meant. Unmasked, Prefix.Contains always reports false.
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return nil, fmt.Errorf("HTTP_TRUSTED_PROXIES entry %q is not an IP address "+
"or CIDR block (for example 10.0.0.0/8, 172.17.0.1 or fd00::/8)", entry)
}
// Unmap first: ::ffff:10.0.0.1 and 10.0.0.1 are the same host, and a
// /128 around the mapped form would not match the peer address Go
// reports for an IPv4 connection.
addr = addr.Unmap()
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
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 "openai (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)
}
}
}
// validateOAuth checks the MCP surface's OAuth identity.
//
// Both values empty is the ordinary case and means the surface is off. Setting
// exactly one is always a mistake — a deployment that named an issuer but no
// resource would serve discovery documents pointing at a resource that does not
// exist — so it is refused at boot rather than at the first client connection.
func (c *Config) validateOAuth() error {
issuer, resource := c.OAuth.Issuer, c.OAuth.Resource
if issuer == "" && resource == "" {
return nil
}
if issuer == "" || resource == "" {
return fmt.Errorf("OAUTH_ISSUER and MCP_RESOURCE must be set together; " +
"one without the other serves discovery documents that point nowhere")
}
for name, raw := range map[string]string{"OAUTH_ISSUER": issuer, "MCP_RESOURCE": resource} {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s must be an absolute URL, got %q", name, raw)
}
// HTTPS everywhere except a loopback development host. OAuth 2.1
// requires every authorization server endpoint to be served over
// HTTPS; a token or code sent over plain http is a token on the wire.
if parsed.Scheme != "https" && !isLoopback(raw) {
return fmt.Errorf("%s must use https (http is permitted only on loopback), got %q", name, raw)
}
if parsed.Fragment != "" {
return fmt.Errorf("%s must not contain a fragment, got %q", name, raw)
}
}
// A same-origin path, never an absolute URL: the authorization endpoint
// redirects here, and an operator-supplied absolute URL would be an open
// redirect one config mistake away.
if !strings.HasPrefix(c.OAuth.LoginPath, "/") || strings.HasPrefix(c.OAuth.LoginPath, "//") {
return fmt.Errorf("OAUTH_LOGIN_PATH must be a same-origin path beginning with a single '/', got %q",
c.OAuth.LoginPath)
}
return nil
}