2026-08-09 22:47:43 +02:00
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
2026-08-16 17:38:15 +02:00
// 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
2026-08-09 22:47:43 +02:00
// 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 (),
2026-08-16 17:38:15 +02:00
MetricsPublic : getenvBool ( "METRICS_PUBLIC" , false ),
AllowInsecureLocalProduction : getenvBool ( "ALLOW_INSECURE_LOCAL_PRODUCTION" , false ),
DBMaxConns : getenvInt ( "DB_MAX_CONNS" , 20 ),
2026-08-09 22:47:43 +02:00
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).
2026-08-16 17:38:15 +02:00
// Exception: insecure local production escape with http loopback WEB_ORIGIN follows SessionSecure
// so cookies work on http://localhost during mislabeled local deploys.
2026-08-09 22:47:43 +02:00
func ( c Config ) CookieSecure () bool {
2026-08-16 17:38:15 +02:00
if c . InsecureLocalProductionActive () && strings . HasPrefix ( strings . ToLower ( strings . TrimSpace ( c . WebOrigin )), "http://" ) {
return c . SessionSecure
}
2026-08-09 22:47:43 +02:00
return c . SessionSecure || c . IsProduction ()
}
2026-08-16 17:38:15 +02:00
// 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 )
}
2026-08-18 01:12:23 +02:00
// 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 :], "." )
}
2026-08-09 22:47:43 +02:00
// 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
}
2026-08-16 17:38:15 +02:00
if err := c . validateProductionWebOrigin (); err != nil {
return err
2026-08-09 22:47:43 +02:00
}
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
}
2026-08-16 17:38:15 +02:00
// 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
}
2026-08-09 22:47:43 +02:00
// 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
}