package config import ( "fmt" "net" "net/url" "os" "strconv" "strings" "time" ) type Config struct { // AppEnv is development|staging|production. Production fails closed on insecure knobs. AppEnv string DatabaseURL string HTTPAddr string WebOrigin string // TrustedProxies lists reverse-proxy CIDRs/IPs allowed to set client IP // headers (X-Forwarded-For, X-Real-IP, True-Client-IP). Empty (default) // ignores those headers — safe for local and direct exposure. TrustedProxies []string // RateLimitReplicas divides HTTP middleware caps in httpapi/ratelimit.go (ceil) // so aggregate under even load approximates documented RPM. Default 1. // Does not affect login lockout, StartLimiter, AIRateLimiter, or email limiters. // Not a shared store — multi-replica hard global caps still need edge/WAF cutover. // Env: RATE_LIMIT_REPLICAS. RateLimitReplicas int // RateLimitMultiReplica is an ops acknowledgment that multiple API replicas run // without a shared limiter store. Boots with a warning when true or replicas > 1. // Env: RATE_LIMIT_MULTI_REPLICA. RateLimitMultiReplica bool // RateLimitBackend is the effective limiter store (always "memory" today). RateLimitBackend string // RateLimitBackendRequested is the raw RATE_LIMIT_BACKEND value when unsupported // (e.g. redis/postgres) so boot can warn that memory was forced. RateLimitBackendRequested string SessionCookieName string SessionSecure bool CSRFCookieName string PublicAPIURL string MigrateMySQLDSN string // MaintenanceMode rejects all non-health traffic with 503 (cutover freeze / emergency). MaintenanceMode bool // ReadOnlyMode rejects mutating methods (POST/PUT/PATCH/DELETE) with 503; GETs still work. ReadOnlyMode bool // HypercareMode shows the tenant “report missing/wrong data” CTA (P1-17); clear to end the window. HypercareMode bool SessionIdleHours int LowCreditsThreshold int SMTPEnabled bool SMTPHost string SMTPPort string SMTPUser string SMTPPassword string SMTPFrom string TokenSigningSecret string // AI processing (worker). Prefer admin platform settings (DB); env is optional bootstrap fallback. OpenAIAPIKey string OpenAIBaseURL string OpenAIModel string // Optional embeddings bootstrap for admin AI role "vectorization". // Empty key/base fall back to OpenAIAPIKey / OpenAIBaseURL at resolve time. OpenAIEmbeddingAPIKey string OpenAIEmbeddingBaseURL string OpenAIEmbeddingModel string ProcessingRPM int ProcessingMaxRetries int ProcessingBatchSize int ProcessingPollInterval time.Duration // worker ClaimNext/Fill idle tick PineconeAPIKey string PineconeHost string PineconeNamespace string UploadDir string // CredentialsEncryptionKey encrypts WooCommerce consumer secrets at rest. // Prefer APP_ENCRYPTION_KEY, else CREDENTIALS_ENCRYPTION_KEY; falls back to TokenSigningSecret / DATABASE_URL. CredentialsEncryptionKey string // AppEncryptionKey encrypts tenant email provider secrets (Resend/SMTP) at rest. // Prefer APP_ENCRYPTION_KEY; falls back to CredentialsEncryptionKey then TokenSigningSecret. AppEncryptionKey string // Marketing email send rate limits (per company, in-process). EmailSendRPM int EmailSendRPH int // EmailDryRun forces transactional + campaign/provider sends to log-only (also forced for Free plan). // Default true when EMAIL_DRY_RUN unset (safe). Preferred source of truth is admin // platform settings (smtp.email_dry_run / mail.email_dry_run); env is bootstrap fallback. EmailDryRun bool // EmailDryRunSet is true when EMAIL_DRY_RUN was explicitly present in the process env. EmailDryRunSet bool // ResendAPIKey is an optional platform-level Resend key used when a company has no key. // Prefer admin platform settings; env is bootstrap fallback only. ResendAPIKey string // EPREL public energy-label enrichment during product processing. EPRELEnabled bool EPRELBaseURL string EPRELTimeout time.Duration EPRELFicheLanguage string EPRELAPIKey string // optional; never log // Stripe billing (Checkout + Customer Portal + webhooks). Empty secret → mock mode. StripeSecretKey string StripeWebhookSecret string StripeMock bool StripePriceIDs map[string]string // "starter:monthly" → price_… // MetricsPublic exposes GET /metrics beyond loopback in production (METRICS_PUBLIC=1). // Non-production always allows scrapes. Production without this flag: loopback only. MetricsPublic bool // AllowInsecureLocalProduction permits loopback WEB_ORIGIN (http or https) when // APP_ENV=production|prod. Env: ALLOW_INSECURE_LOCAL_PRODUCTION=1. // Public (non-loopback) origins still require https. Use only for local systemd / // npm run dev mislabeled as production — never for a public deploy. AllowInsecureLocalProduction bool // Postgres pgx pool (api + worker). Defaults preserve historical NewPool hardcodes // and add idle recycle + statement_timeout for multi-tenant churn. // See docs/ops-runtime.md § Postgres pgx pool and db.PoolOptions comments. DBMaxConns int DBMinConns int DBMaxConnLifetime time.Duration DBMaxConnLifetimeJitter time.Duration DBMaxConnIdleTime time.Duration DBHealthCheckPeriod time.Duration DBStatementTimeout time.Duration } func Load() (Config, error) { // Monorepo root .env is the single local source of truth (see loadDotEnv). loadDotEnv() appEnv := getenv("APP_ENV", "development") cfg := Config{ AppEnv: appEnv, DatabaseURL: getenv("DATABASE_URL", "postgres://descrybe:descrybe@localhost:5433/descrybe?sslmode=disable"), HTTPAddr: getenv("HTTP_ADDR", ":28471"), WebOrigin: getenv("WEB_ORIGIN", "http://localhost:28472"), TrustedProxies: parseCSVList(os.Getenv("TRUSTED_PROXIES")), RateLimitReplicas: getenvInt("RATE_LIMIT_REPLICAS", 1), RateLimitMultiReplica: getenvBool("RATE_LIMIT_MULTI_REPLICA", false), RateLimitBackend: "memory", SessionCookieName: getenv("SESSION_COOKIE_NAME", "descrybe_session"), // Default Secure=true when APP_ENV is production|prod so cookies are HTTPS-only // even if SESSION_SECURE is unset; explicit false still fails closed in validate. SessionSecure: getenvBool("SESSION_SECURE", isProductionEnvValue(appEnv)), CSRFCookieName: getenv("CSRF_COOKIE_NAME", "descrybe_csrf"), PublicAPIURL: getenv("PUBLIC_API_URL", "http://localhost:28471"), MigrateMySQLDSN: os.Getenv("MIGRATE_MYSQL_DSN"), MaintenanceMode: getenvBool("MAINTENANCE_MODE", false), ReadOnlyMode: getenvBool("READ_ONLY_MODE", false), HypercareMode: getenvBool("HYPERCARE_MODE", false), SessionIdleHours: getenvInt("SESSION_IDLE_HOURS", 24), LowCreditsThreshold: getenvInt("LOW_CREDITS_THRESHOLD", 100), SMTPEnabled: getenvBool("SMTP_ENABLED", false), SMTPHost: os.Getenv("SMTP_HOST"), SMTPPort: getenv("SMTP_PORT", "587"), SMTPUser: os.Getenv("SMTP_USER"), SMTPPassword: os.Getenv("SMTP_PASSWORD"), SMTPFrom: getenv("SMTP_FROM", "noreply@localhost"), TokenSigningSecret: os.Getenv("TOKEN_SIGNING_SECRET"), OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), OpenAIBaseURL: getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), OpenAIModel: getenv("OPENAI_MODEL", "gpt-4o-mini"), OpenAIEmbeddingAPIKey: os.Getenv("OPENAI_EMBEDDING_API_KEY"), OpenAIEmbeddingBaseURL: os.Getenv("OPENAI_EMBEDDING_BASE_URL"), OpenAIEmbeddingModel: getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"), ProcessingRPM: getenvInt("PROCESSING_RPM", 60), ProcessingMaxRetries: getenvInt("PROCESSING_MAX_RETRIES", 3), ProcessingBatchSize: getenvInt("PROCESSING_BATCH_SIZE", 100), ProcessingPollInterval: getenvDuration("PROCESSING_POLL_INTERVAL", 250*time.Millisecond), PineconeAPIKey: os.Getenv("PINECONE_API_KEY"), PineconeHost: os.Getenv("PINECONE_HOST"), PineconeNamespace: getenv("PINECONE_NAMESPACE", ""), UploadDir: getenv("UPLOAD_DIR", "data/uploads"), CredentialsEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"), AppEncryptionKey: firstEnv("APP_ENCRYPTION_KEY", "CREDENTIALS_ENCRYPTION_KEY"), EmailDryRun: getenvBool("EMAIL_DRY_RUN", true), EmailDryRunSet: strings.TrimSpace(os.Getenv("EMAIL_DRY_RUN")) != "", ResendAPIKey: os.Getenv("RESEND_API_KEY"), EmailSendRPM: getenvInt("EMAIL_SEND_RPM", 30), EmailSendRPH: getenvInt("EMAIL_SEND_RPH", 500), EPRELEnabled: getenvBool("EPREL_ENABLED", true), EPRELBaseURL: getenv("EPREL_BASE_URL", "https://eprel.ec.europa.eu/api"), EPRELTimeout: getenvDuration("EPREL_TIMEOUT", 10*time.Second), EPRELFicheLanguage: getenv("EPREL_FICHE_LANGUAGE", "EN"), EPRELAPIKey: os.Getenv("EPREL_API_KEY"), StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), StripeMock: getenvBool("STRIPE_MOCK", false), StripePriceIDs: loadStripePriceIDs(), MetricsPublic: getenvBool("METRICS_PUBLIC", false), AllowInsecureLocalProduction: getenvBool("ALLOW_INSECURE_LOCAL_PRODUCTION", false), DBMaxConns: getenvInt("DB_MAX_CONNS", 20), DBMinConns: getenvInt("DB_MIN_CONNS", 2), DBMaxConnLifetime: getenvDuration("DB_MAX_CONN_LIFETIME", time.Hour), DBMaxConnLifetimeJitter: getenvDurationAllowZero("DB_MAX_CONN_LIFETIME_JITTER", 6*time.Minute), DBMaxConnIdleTime: getenvDuration("DB_MAX_CONN_IDLE_TIME", 5*time.Minute), DBHealthCheckPeriod: getenvDuration("DB_HEALTH_CHECK_PERIOD", time.Minute), DBStatementTimeout: getenvDurationAllowZero("DB_STATEMENT_TIMEOUT", 30*time.Second), } if strings.TrimSpace(cfg.DatabaseURL) == "" { return Config{}, fmt.Errorf("DATABASE_URL is required") } if cfg.RateLimitReplicas < 1 { cfg.RateLimitReplicas = 1 } if cfg.RateLimitReplicas > 128 { cfg.RateLimitReplicas = 128 } if requested := strings.ToLower(strings.TrimSpace(os.Getenv("RATE_LIMIT_BACKEND"))); requested != "" && requested != "memory" { cfg.RateLimitBackendRequested = requested } cfg.RateLimitBackend = "memory" if cfg.EPRELEnabled { if err := validatePublicHTTPBaseURL(cfg.EPRELBaseURL, "EPREL_BASE_URL"); err != nil { return Config{}, err } } if err := cfg.validate(); err != nil { return Config{}, err } cfg.WebOrigin = normalizeWebOrigin(cfg.WebOrigin) return cfg, nil } // IsProduction reports whether APP_ENV is production (or prod). func (c Config) IsProduction() bool { return isProductionEnvValue(c.AppEnv) } // CookieSecure is true when session/CSRF cookies must carry the Secure flag. // Prefer SessionSecure; also force Secure when APP_ENV is production (defense in depth). // Exception: insecure local production escape with http loopback WEB_ORIGIN follows SessionSecure // so cookies work on http://localhost during mislabeled local deploys. func (c Config) CookieSecure() bool { if c.InsecureLocalProductionActive() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.WebOrigin)), "http://") { return c.SessionSecure } return c.SessionSecure || c.IsProduction() } // InsecureLocalProductionActive reports APP_ENV=production|prod with the explicit // loopback escape (ALLOW_INSECURE_LOCAL_PRODUCTION) and a loopback WEB_ORIGIN. func (c Config) InsecureLocalProductionActive() bool { return c.IsProduction() && c.AllowInsecureLocalProduction && isLoopbackWebOriginHost(c.WebOrigin) } // SessionCookieParentDomain derives the session cookie Domain attribute from // WEB_ORIGIN and PUBLIC_API_URL — no extra env needed. When web and API run on // sibling hosts of one parent domain (descrybe.io + api.descrybe.io), the // shared parent is returned so the browser also sends the session cookie to // the web host; SvelteKit SSR gates (/admin) forward it to /api/auth/me and // would otherwise always see 401. Same hostname (localhost dev, single-host // deploys), IPs, or unrelated hosts → "" (host-only cookie, old behavior). func (c Config) SessionCookieParentDomain() string { web := hostnameOfURL(c.WebOrigin) api := hostnameOfURL(c.PublicAPIURL) if web == "" || api == "" || web == api { return "" } if net.ParseIP(web) != nil || net.ParseIP(api) != nil || !strings.Contains(web, ".") || !strings.Contains(api, ".") { return "" } // Direct parent/child: one host is the other's registrable parent. if strings.HasSuffix(api, "."+web) { return web } if strings.HasSuffix(web, "."+api) { return api } // Sibling subdomains (app.x.y + api.x.y): share the deepest common suffix, // but only when it has at least two labels (never a bare TLD). if suffix := commonDotSuffix(web, api); strings.Count(suffix, ".") >= 1 { return suffix } return "" } func hostnameOfURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "" } return strings.ToLower(u.Hostname()) } // commonDotSuffix returns the longest label-aligned common suffix of two hostnames // ("app.descrybe.io", "api.descrybe.io" → "descrybe.io"); "" when nothing matches. func commonDotSuffix(a, b string) string { la := strings.Split(a, ".") lb := strings.Split(b, ".") n := 0 for n < len(la) && n < len(lb) { if la[len(la)-1-n] != lb[len(lb)-1-n] { break } n++ } // Never return one of the full hostnames itself (that is the parent/child case). if n == 0 || n == len(la) || n == len(lb) { return "" } return strings.Join(la[len(la)-n:], ".") } // ShouldWarnRateLimits reports whether operators opted into multi-replica rate-limit // awareness or requested an unsupported shared backend. func (c Config) ShouldWarnRateLimits() bool { return c.RateLimitMultiReplica || c.RateLimitReplicas > 1 || c.RateLimitBackendRequested != "" } // RateLimitWarningMessage is a stable ops-facing explanation for in-process limits. func (c Config) RateLimitWarningMessage() string { msg := "HTTP rate limits are in-process only (no Redis/shared store); multi-replica hard caps need edge/WAF; optional RATE_LIMIT_REPLICAS divides HTTP middleware caps only (not lockout/StartLimiter/AI/email)" if c.RateLimitBackendRequested != "" { msg += "; RATE_LIMIT_BACKEND=" + c.RateLimitBackendRequested + " is not implemented — using memory" } return msg } // IsProductionEnv reports whether the live APP_ENV is production or prod. // Used by credential crypto helpers that do not hold a Config value. func IsProductionEnv() bool { return isProductionEnvValue(os.Getenv("APP_ENV")) } func isProductionEnvValue(e string) bool { e = strings.ToLower(strings.TrimSpace(e)) return e == "production" || e == "prod" } func (c Config) validate() error { if err := validateWebOrigin(c.WebOrigin); err != nil { return err } if _, err := ParseTrustedProxyNets(c.TrustedProxies); err != nil { return err } if err := c.validateDBPool(); err != nil { return err } if c.ProcessingPollInterval <= 0 { return fmt.Errorf("PROCESSING_POLL_INTERVAL must be > 0") } if err := c.validateSMTPConfig(); err != nil { return err } if !c.IsProduction() { return nil } if err := c.validateProductionWebOrigin(); err != nil { return err } if strings.TrimSpace(c.AppEncryptionKey) == "" { return fmt.Errorf("APP_ENCRYPTION_KEY is required in production") } if strings.TrimSpace(c.TokenSigningSecret) == "" { return fmt.Errorf("TOKEN_SIGNING_SECRET is required in production") } if c.StripeMock { return fmt.Errorf("STRIPE_MOCK must be false in production") } // Stripe secret/webhook keys may live in admin platform_settings; boot does not // require env STRIPE_* (checkout/webhooks fail closed until configured). if err := c.validateProductionMail(); err != nil { return err } return nil } // validateProductionWebOrigin fails closed for public production: https + non-loopback // WEB_ORIGIN and SESSION_SECURE. Loopback http(s) is allowed only with the explicit // ALLOW_INSECURE_LOCAL_PRODUCTION escape (local systemd / npm run dev mislabeled as prod). func (c Config) validateProductionWebOrigin() error { origin := strings.TrimSpace(c.WebOrigin) https := strings.HasPrefix(strings.ToLower(origin), "https://") loopback := isLoopbackWebOriginHost(origin) localEscape := c.AllowInsecureLocalProduction && loopback if localEscape { // SESSION_SECURE is optional for http://localhost escape so cookies work locally. if https && !c.SessionSecure { return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production and WEB_ORIGIN is https") } return nil } // Prefer WEB_ORIGIN diagnostics for the common local-mislabeled-production crash loop // (http://localhost + APP_ENV=production) before SESSION_SECURE. if !https { if loopback { return fmt.Errorf("WEB_ORIGIN must be https in production (got %q). Public deploy: set WEB_ORIGIN=https://your.domain. Local systemd/npm run dev: set APP_ENV=development, or ALLOW_INSECURE_LOCAL_PRODUCTION=1 with loopback WEB_ORIGIN only — otherwise api/worker exit and leave processing_jobs status=running forever", origin) } return fmt.Errorf("WEB_ORIGIN must be https in production (got %q); set WEB_ORIGIN=https://your.public.domain", origin) } if loopback { return fmt.Errorf("WEB_ORIGIN must not be localhost/loopback in production (got %q). Public deploy: use your https domain. Local only: APP_ENV=development or ALLOW_INSECURE_LOCAL_PRODUCTION=1", origin) } if !c.SessionSecure { return fmt.Errorf("SESSION_SECURE=true is required when APP_ENV=production") } return nil } // validateSMTPConfig no longer fails closed at boot: SMTP credentials live in // admin platform settings (with optional env fallback). Incomplete SMTP_ENABLED // env is ignored until admin configures delivery. func (c Config) validateSMTPConfig() error { return nil } // validateProductionMail no longer requires RESEND_API_KEY / SMTP_HOST in env. // Live delivery is gated at send time via platform settings + tenant providers. // Still rejects localhost From when SMTP_ENABLED env is true (misconfig hint). func (c Config) validateProductionMail() error { if c.SMTPEnabled { from := strings.ToLower(strings.TrimSpace(c.SMTPFrom)) if from != "" && strings.HasSuffix(from, "@localhost") { return fmt.Errorf("SMTP_FROM must not be a localhost address in production when SMTP_ENABLED=true") } } return nil } func (c Config) validateDBPool() error { if c.DBMaxConns < 1 { return fmt.Errorf("DB_MAX_CONNS must be >= 1") } if c.DBMinConns < 0 { return fmt.Errorf("DB_MIN_CONNS must be >= 0") } if c.DBMinConns > c.DBMaxConns { return fmt.Errorf("DB_MIN_CONNS must be <= DB_MAX_CONNS") } if c.DBMaxConnLifetime <= 0 { return fmt.Errorf("DB_MAX_CONN_LIFETIME must be > 0") } if c.DBMaxConnLifetimeJitter < 0 { return fmt.Errorf("DB_MAX_CONN_LIFETIME_JITTER must be >= 0") } if c.DBMaxConnIdleTime <= 0 { return fmt.Errorf("DB_MAX_CONN_IDLE_TIME must be > 0") } if c.DBHealthCheckPeriod <= 0 { return fmt.Errorf("DB_HEALTH_CHECK_PERIOD must be > 0") } // Statement timeout may be 0 to disable the GUC. if c.DBStatementTimeout < 0 { return fmt.Errorf("DB_STATEMENT_TIMEOUT must be >= 0") } return nil } func validateWebOrigin(raw string) error { raw = strings.TrimSpace(raw) if raw == "" { return fmt.Errorf("WEB_ORIGIN is required") } if raw == "*" { return fmt.Errorf("WEB_ORIGIN must not be * (credentials CORS)") } u, err := url.Parse(raw) if err != nil { return fmt.Errorf("WEB_ORIGIN is invalid") } scheme := strings.ToLower(u.Scheme) if scheme != "http" && scheme != "https" { return fmt.Errorf("WEB_ORIGIN must be an absolute http(s) origin") } if u.Host == "" { return fmt.Errorf("WEB_ORIGIN host is required") } if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.User != nil { return fmt.Errorf("WEB_ORIGIN must be an origin only (no path)") } return nil } // normalizeWebOrigin returns scheme://host (no trailing slash/path) for CORS exact match. func normalizeWebOrigin(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Scheme == "" || u.Host == "" { return strings.TrimSpace(raw) } return strings.ToLower(u.Scheme) + "://" + u.Host } // CORSAllowedOrigins returns WEB_ORIGIN plus the localhost↔127.0.0.1 twin when the // configured origin is already loopback. Browsers treat those hostnames as distinct // origins; without the twin, Vite opened via the other hostname fails credentialed CORS. // Production rejects loopback WEB_ORIGIN, so this never widens a real deploy origin. func CORSAllowedOrigins(webOrigin string) []string { origin := normalizeWebOrigin(webOrigin) if origin == "" { return nil } out := []string{origin} if twin := loopbackOriginTwin(origin); twin != "" && twin != origin { out = append(out, twin) } return out } func loopbackOriginTwin(origin string) string { u, err := url.Parse(strings.TrimSpace(origin)) if err != nil || u.Scheme == "" || u.Host == "" { return "" } host := strings.ToLower(u.Hostname()) var twinHost string switch host { case "localhost": twinHost = "127.0.0.1" case "127.0.0.1": twinHost = "localhost" default: return "" } scheme := strings.ToLower(u.Scheme) if port := u.Port(); port != "" { return scheme + "://" + twinHost + ":" + port } return scheme + "://" + twinHost } func isLoopbackWebOriginHost(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return false } host := strings.ToLower(u.Hostname()) return host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasSuffix(host, ".localhost") } // ParseTrustedProxyNets parses TRUSTED_PROXIES entries (CIDR or single IP). func ParseTrustedProxyNets(entries []string) ([]*net.IPNet, error) { out := make([]*net.IPNet, 0, len(entries)) for _, raw := range entries { raw = strings.TrimSpace(raw) if raw == "" { continue } if strings.Contains(raw, "/") { _, n, err := net.ParseCIDR(raw) if err != nil { return nil, fmt.Errorf("TRUSTED_PROXIES invalid CIDR %q", raw) } out = append(out, n) continue } ip := net.ParseIP(raw) if ip == nil { return nil, fmt.Errorf("TRUSTED_PROXIES invalid IP %q", raw) } if v4 := ip.To4(); v4 != nil { out = append(out, &net.IPNet{IP: v4, Mask: net.CIDRMask(32, 32)}) continue } out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}) } return out, nil } func parseCSVList(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } if len(out) == 0 { return nil } return out } func validatePublicHTTPBaseURL(raw, name string) error { raw = strings.TrimSpace(raw) if raw == "" { return fmt.Errorf("%s is required when enabled", name) } // Lazy import avoided — parse manually for scheme/host only. lower := strings.ToLower(raw) if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") { return fmt.Errorf("%s must be http(s)", name) } without := raw if i := strings.Index(without, "://"); i >= 0 { without = without[i+3:] } hostport := without if i := strings.IndexAny(hostport, "/?#"); i >= 0 { hostport = hostport[:i] } host := hostport if i := strings.LastIndex(hostport, ":"); i >= 0 { // strip port; handle IPv6 [::1]:port lightly by rejecting brackets for now if !strings.HasPrefix(hostport, "[") { host = hostport[:i] } } host = strings.ToLower(strings.TrimSpace(host)) if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") { return fmt.Errorf("%s host is not allowed (SSRF)", name) } // Literal private IPs only (hostname DNS rebinding is operator-controlled for this official API URL). if isLiteralPrivateHost(host) { return fmt.Errorf("%s must not point at a private IP", name) } return nil } func isLiteralPrivateHost(host string) bool { // Minimal check without importing net into every path — cover common private literals. if host == "127.0.0.1" || host == "0.0.0.0" || host == "::1" { return true } if strings.HasPrefix(host, "10.") || strings.HasPrefix(host, "192.168.") || strings.HasPrefix(host, "169.254.") { return true } if strings.HasPrefix(host, "172.") { parts := strings.Split(host, ".") if len(parts) >= 2 { var n int if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil && n >= 16 && n <= 31 { return true } } } return false } func loadStripePriceIDs() map[string]string { out := map[string]string{} pairs := []struct { key string env string }{ {"starter:monthly", "STRIPE_PRICE_STARTER_MONTHLY"}, {"starter:yearly", "STRIPE_PRICE_STARTER_YEARLY"}, {"plus:monthly", "STRIPE_PRICE_PLUS_MONTHLY"}, {"plus:yearly", "STRIPE_PRICE_PLUS_YEARLY"}, {"growth:monthly", "STRIPE_PRICE_GROWTH_MONTHLY"}, {"growth:yearly", "STRIPE_PRICE_GROWTH_YEARLY"}, {"business:monthly", "STRIPE_PRICE_BUSINESS_MONTHLY"}, {"business:yearly", "STRIPE_PRICE_BUSINESS_YEARLY"}, {"scale:monthly", "STRIPE_PRICE_SCALE_MONTHLY"}, {"scale:yearly", "STRIPE_PRICE_SCALE_YEARLY"}, // Credit packs — keep IDs aligned with billing.DefaultCreditPacks. {"pack:tiny", "STRIPE_PRICE_PACK_TINY"}, {"pack:small", "STRIPE_PRICE_PACK_SMALL"}, {"pack:medium", "STRIPE_PRICE_PACK_MEDIUM"}, {"pack:large", "STRIPE_PRICE_PACK_LARGE"}, {"pack:xl", "STRIPE_PRICE_PACK_XL"}, {"pack:xxl", "STRIPE_PRICE_PACK_XXL"}, {"pack:mega", "STRIPE_PRICE_PACK_MEGA"}, } for _, p := range pairs { if v := strings.TrimSpace(os.Getenv(p.env)); v != "" { out[p.key] = v } } return out } func getenv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // firstEnv returns the first non-empty process env among keys (no fallback default). func firstEnv(keys ...string) string { for _, key := range keys { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } } return "" } func getenvBool(key string, fallback bool) bool { v := os.Getenv(key) if v == "" { return fallback } b, err := strconv.ParseBool(v) if err != nil { return fallback } return b } func getenvInt(key string, fallback int) int { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n } // getenvDuration accepts Go durations ("10s", "500ms") or integer seconds ("10"). func getenvDuration(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } if d, err := time.ParseDuration(v); err == nil && d > 0 { return d } if n, err := strconv.Atoi(v); err == nil && n > 0 { return time.Duration(n) * time.Second } return fallback } // getenvDurationAllowZero is like getenvDuration but accepts 0 (e.g. disable statement_timeout). func getenvDurationAllowZero(key string, fallback time.Duration) time.Duration { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } if d, err := time.ParseDuration(v); err == nil && d >= 0 { return d } if n, err := strconv.Atoi(v); err == nil && n >= 0 { return time.Duration(n) * time.Second } return fallback }