// 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://" } 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 }