767 lines
31 KiB
Go
767 lines
31 KiB
Go
// Package config loads and validates the backend's runtime configuration.
|
|
//
|
|
// Configuration comes from the process environment. A .env file in the
|
|
// repository root is read first as a convenience for local development, and
|
|
// never overrides a variable that is already set — so an explicit
|
|
// `DATABASE_PASSWORD=… go run ./cmd/api` always wins over the file.
|
|
//
|
|
// Nothing here has a credential baked in. Load fails loudly rather than
|
|
// falling back to a default host, database or user, because a silent default
|
|
// is how a development process ends up pointed at the wrong database.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
type DBConfig struct {
|
|
Host string
|
|
Port int
|
|
Name string
|
|
User string
|
|
Password string
|
|
Schema string
|
|
SSLMode string
|
|
MaxOpenConns int32
|
|
MinIdleConns int32
|
|
ConnMaxLifetime time.Duration
|
|
ConnectTimeout time.Duration
|
|
StatementTimeout time.Duration
|
|
}
|
|
|
|
// DSN builds a libpq-style connection URL.
|
|
//
|
|
// Every component is URL-escaped: the local database is called "Krow-force",
|
|
// which is both mixed-case and hyphenated, and a password may contain anything
|
|
// at all. Escaping is not optional here.
|
|
func (d DBConfig) DSN() string {
|
|
u := &url.URL{
|
|
Scheme: "postgres",
|
|
User: url.UserPassword(d.User, d.Password),
|
|
Host: fmt.Sprintf("%s:%d", d.Host, d.Port),
|
|
Path: "/" + d.Name,
|
|
}
|
|
q := u.Query()
|
|
q.Set("sslmode", d.SSLMode)
|
|
// Pin the schema on every connection so no query can accidentally resolve
|
|
// against a different one, and so nothing reaches for a system schema.
|
|
q.Set("search_path", d.Schema)
|
|
q.Set("connect_timeout", strconv.Itoa(int(d.ConnectTimeout.Seconds())))
|
|
q.Set("statement_timeout", strconv.Itoa(int(d.StatementTimeout.Milliseconds())))
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// Redacted returns the DSN with the password replaced, for logs.
|
|
func (d DBConfig) Redacted() string {
|
|
u, err := url.Parse(d.DSN())
|
|
if err != nil {
|
|
return "postgres://<unparseable>"
|
|
}
|
|
if _, hasPassword := u.User.Password(); hasPassword {
|
|
u.User = url.UserPassword(u.User.Username(), "xxxxx")
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// Load reads the environment, applies defaults and validates the result.
|
|
func Load() (*Config, error) {
|
|
loadDotEnv(".env")
|
|
|
|
var missing []string
|
|
required := func(key string) string {
|
|
v := strings.TrimSpace(os.Getenv(key))
|
|
if v == "" {
|
|
missing = append(missing, key)
|
|
}
|
|
return v
|
|
}
|
|
|
|
cfg := &Config{
|
|
AppEnv: withDefault("APP_ENV", "development"),
|
|
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
|
|
HTTP: HTTPConfig{
|
|
Host: withDefault("HTTP_HOST", "127.0.0.1"),
|
|
Port: intDefault("HTTP_PORT", 8080),
|
|
ReadTimeout: durationDefault("HTTP_READ_TIMEOUT", 15*time.Second),
|
|
WriteTimeout: durationDefault("HTTP_WRITE_TIMEOUT", 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"),
|
|
},
|
|
Agents: AgentsConfig{
|
|
CuratedPath: withDefault("CURATED_AGENTS_PATH", "./agents"),
|
|
},
|
|
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 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)
|
|
}
|
|
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 "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)
|
|
}
|
|
}
|
|
}
|