2026-07-10 12:54:45 +00:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-13 08:59:13 +00:00
|
|
|
|
"fmt"
|
2026-07-15 15:23:59 +00:00
|
|
|
|
"net/url"
|
2026-07-10 12:54:45 +00:00
|
|
|
|
"os"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
"apps/backend/internal/lib/secretbox"
|
|
|
|
|
|
|
2026-07-10 12:54:45 +00:00
|
|
|
|
"github.com/zeromicro/go-zero/core/stores/cache"
|
|
|
|
|
|
"github.com/zeromicro/go-zero/rest"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Config — go-zero 風格:RestConf + CacheRedis + Mongo URI.
|
|
|
|
|
|
type Config struct {
|
|
|
|
|
|
rest.RestConf
|
|
|
|
|
|
|
|
|
|
|
|
// CacheRedis is go-zero cache.CacheConf (feeds monc Redis 快取).
|
|
|
|
|
|
CacheRedis cache.CacheConf
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Redis struct {
|
|
|
|
|
|
Namespace string `json:",optional,default=haixun:dev:v1"`
|
|
|
|
|
|
AIModelCacheFingerprintSecret string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
|
|
|
|
|
|
Mongo struct {
|
|
|
|
|
|
URI string
|
|
|
|
|
|
Database string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Auth struct {
|
|
|
|
|
|
AccessSecret string
|
|
|
|
|
|
AccessExpire int64
|
|
|
|
|
|
RefreshSecret string
|
|
|
|
|
|
RefreshExpire int64
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
// MemberSettingsEncryption is dedicated to member BYOK secrets. Never reuse
|
|
|
|
|
|
// Auth or Scout secrets here.
|
|
|
|
|
|
MemberSettingsEncryption struct {
|
|
|
|
|
|
CurrentKeyID string `json:",optional"`
|
|
|
|
|
|
Key string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// CORSAllowedOrigins is an explicit browser-origin allowlist.
|
|
|
|
|
|
CORSAllowedOrigins []string `json:",optional"`
|
|
|
|
|
|
Platform struct {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
// AIKey — legacy alias for XAIKey
|
|
|
|
|
|
AIKey string `json:",optional"`
|
|
|
|
|
|
// XAIKey — platform BYOK for xAI (api.x.ai)
|
|
|
|
|
|
XAIKey string `json:",optional"`
|
|
|
|
|
|
// OpenCodeKey — platform BYOK for OpenCode Go (opencode.ai/zen/go)
|
|
|
|
|
|
OpenCodeKey string `json:",optional"`
|
2026-07-10 12:54:45 +00:00
|
|
|
|
ExaKey string `json:",optional"`
|
|
|
|
|
|
ThreadsAppId string `json:",optional"`
|
|
|
|
|
|
ThreadsAppSecret string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// Scout keeps browser-session credentials separate from JWT and public APIs.
|
|
|
|
|
|
Scout struct {
|
|
|
|
|
|
SessionSecret string `json:",optional"`
|
|
|
|
|
|
CrawlerEndpoint string `json:",optional"`
|
|
|
|
|
|
CrawlerToken string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
Worker struct {
|
|
|
|
|
|
ID string `json:",default=worker-1"`
|
|
|
|
|
|
PollIntervalMs int `json:",default=2000"`
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// Seed is used only by cmd/seeder; gateway and worker never read or log it.
|
|
|
|
|
|
Seed struct {
|
|
|
|
|
|
AdminPassword string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
// Bcrypt cost (stand-alone Bcrypt.Cost)
|
|
|
|
|
|
Bcrypt struct {
|
|
|
|
|
|
Cost int `json:",default=10"`
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// OAuth third-party. Empty provider config disables that provider.
|
2026-07-10 12:54:45 +00:00
|
|
|
|
OAuth struct {
|
|
|
|
|
|
GoogleClientID string `json:",optional"`
|
|
|
|
|
|
LineClientID string `json:",optional"`
|
|
|
|
|
|
LineClientSecret string `json:",optional"`
|
|
|
|
|
|
LineRedirectURI string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Stripe struct {
|
|
|
|
|
|
Enabled bool `json:",optional"`
|
|
|
|
|
|
SecretKey string `json:",optional"`
|
|
|
|
|
|
WebhookSecret string `json:",optional"`
|
|
|
|
|
|
StarterPriceID string `json:",optional"`
|
|
|
|
|
|
ProPriceID string `json:",optional"`
|
|
|
|
|
|
SuccessURL string `json:",optional"`
|
|
|
|
|
|
CancelURL string `json:",optional"`
|
|
|
|
|
|
PortalReturnURL string `json:",optional"`
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
// Mail — 驗證碼/重設密碼寄信(SMTP;空 Host 則只打 log + 可回 mock_code)
|
|
|
|
|
|
Mail struct {
|
|
|
|
|
|
Sender string `json:",optional"` // e.g. "Harbor Desk <noreply@example.com>"
|
|
|
|
|
|
SMTP struct {
|
|
|
|
|
|
Host string `json:",optional"`
|
|
|
|
|
|
Port int `json:",default=587"`
|
|
|
|
|
|
User string `json:",optional"`
|
|
|
|
|
|
Password string `json:",optional"`
|
|
|
|
|
|
} `json:",optional"`
|
|
|
|
|
|
// DevExposeCode: API 回 mock_code(本機沒信箱時用)。正式請 false。
|
|
|
|
|
|
DevExposeCode bool `json:",default=true"`
|
|
|
|
|
|
} `json:",optional"`
|
|
|
|
|
|
// PublicWebBase — 前端 origin,用於重設密碼信內連結/logo(勿尾斜線)
|
|
|
|
|
|
// 例:http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net
|
|
|
|
|
|
PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
|
2026-07-13 01:15:30 +00:00
|
|
|
|
// PublicAPIBase — 對外 API origin(Threads OAuth redirect_uri 必須 https 公開網址)
|
|
|
|
|
|
// 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port
|
|
|
|
|
|
// 例:https://threads-tool-dev.30cm.net
|
|
|
|
|
|
PublicAPIBase string `json:",optional"`
|
2026-07-10 12:54:45 +00:00
|
|
|
|
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg
|
|
|
|
|
|
Brand struct {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Name string `json:",optional"`
|
2026-07-10 12:54:45 +00:00
|
|
|
|
LogoURL string `json:",optional"` // absolute URL
|
2026-07-15 15:23:59 +00:00
|
|
|
|
Copyright string `json:",optional"`
|
2026-07-10 12:54:45 +00:00
|
|
|
|
} `json:",optional"`
|
|
|
|
|
|
// ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟)
|
|
|
|
|
|
ObjectStorage struct {
|
|
|
|
|
|
Endpoint string `json:",optional"` // 必填,e.g. http://127.0.0.1:9000
|
|
|
|
|
|
Region string `json:",optional,default=us-east-1"`
|
|
|
|
|
|
Bucket string `json:",optional,default=haixun-assets"`
|
|
|
|
|
|
AccessKey string `json:",optional"`
|
|
|
|
|
|
SecretKey string `json:",optional"`
|
|
|
|
|
|
UsePathStyle bool `json:",optional,default=true"`
|
|
|
|
|
|
PublicBaseURL string `json:",optional"` // 公開讀取前綴,勿尾斜線
|
|
|
|
|
|
} `json:",optional"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ApplyEnv overlays secrets from environment.
|
|
|
|
|
|
func (c *Config) ApplyEnv() {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if strings.TrimSpace(c.Redis.Namespace) == "" {
|
|
|
|
|
|
c.Redis.Namespace = "haixun:dev:v1"
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.Brand.Name == "" {
|
|
|
|
|
|
c.Brand.Name = "Harbor Desk"
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.Brand.Copyright == "" {
|
|
|
|
|
|
c.Brand.Copyright = "© Harbor Desk"
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("MONGO_URI"); v != "" {
|
|
|
|
|
|
c.Mongo.URI = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("MONGO_DATABASE"); v != "" {
|
|
|
|
|
|
c.Mongo.Database = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("REDIS_HOST"); v != "" && len(c.CacheRedis) > 0 {
|
|
|
|
|
|
c.CacheRedis[0].RedisConf.Host = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(c.CacheRedis) > 0 {
|
|
|
|
|
|
// monc 快取 Redis 帳密(本機 infra 常開 requirepass)
|
|
|
|
|
|
if v := os.Getenv("REDIS_PASSWORD"); v != "" {
|
|
|
|
|
|
c.CacheRedis[0].RedisConf.Pass = v
|
|
|
|
|
|
} else if v := os.Getenv("HAIXUN_REDIS_PASSWORD"); v != "" {
|
|
|
|
|
|
c.CacheRedis[0].RedisConf.Pass = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("HAIXUN_REDIS_ADDR"); v != "" {
|
|
|
|
|
|
c.CacheRedis[0].RedisConf.Host = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if v := firstEnv("REDIS_NAMESPACE", "HAIXUN_REDIS_NAMESPACE"); v != "" {
|
|
|
|
|
|
c.Redis.Namespace = strings.Trim(v, ":")
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET"); v != "" {
|
|
|
|
|
|
c.Redis.AIModelCacheFingerprintSecret = v
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
|
|
|
|
|
|
c.Auth.AccessSecret = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
|
|
|
|
|
|
c.Auth.RefreshSecret = v
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if v := os.Getenv("STRIPE_ENABLED"); v != "" {
|
|
|
|
|
|
c.Stripe.Enabled = v == "1" || strings.EqualFold(v, "true")
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_SECRET_KEY"); v != "" {
|
|
|
|
|
|
c.Stripe.SecretKey = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_WEBHOOK_SECRET"); v != "" {
|
|
|
|
|
|
c.Stripe.WebhookSecret = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_STARTER_PRICE_ID"); v != "" {
|
|
|
|
|
|
c.Stripe.StarterPriceID = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_PRO_PRICE_ID"); v != "" {
|
|
|
|
|
|
c.Stripe.ProPriceID = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_SUCCESS_URL"); v != "" {
|
|
|
|
|
|
c.Stripe.SuccessURL = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_CANCEL_URL"); v != "" {
|
|
|
|
|
|
c.Stripe.CancelURL = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("STRIPE_PORTAL_RETURN_URL"); v != "" {
|
|
|
|
|
|
c.Stripe.PortalReturnURL = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY_ID"); v != "" {
|
|
|
|
|
|
c.MemberSettingsEncryption.CurrentKeyID = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY"); v != "" {
|
|
|
|
|
|
c.MemberSettingsEncryption.Key = v
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
|
|
|
|
|
|
c.Platform.AIKey = v
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if v := firstEnv("PLATFORM_XAI_KEY", "XAI_API_KEY"); v != "" {
|
|
|
|
|
|
c.Platform.XAIKey = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("PLATFORM_OPENCODE_KEY", "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"); v != "" {
|
|
|
|
|
|
c.Platform.OpenCodeKey = v
|
|
|
|
|
|
}
|
|
|
|
|
|
// legacy AIKey → XAI when XAI empty
|
|
|
|
|
|
if c.Platform.XAIKey == "" && c.Platform.AIKey != "" {
|
|
|
|
|
|
c.Platform.XAIKey = c.Platform.AIKey
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" {
|
|
|
|
|
|
c.Platform.ExaKey = v
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if v := firstEnv("THREADS_APP_ID", "HAIXUN_THREADS_APP_ID"); v != "" {
|
|
|
|
|
|
c.Platform.ThreadsAppId = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" {
|
|
|
|
|
|
c.Platform.ThreadsAppSecret = v
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if v := os.Getenv("SCOUT_SESSION_SECRET"); v != "" {
|
|
|
|
|
|
c.Scout.SessionSecret = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("SCOUT_CRAWLER_ENDPOINT"); v != "" {
|
|
|
|
|
|
c.Scout.CrawlerEndpoint = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("SCOUT_CRAWLER_TOKEN"); v != "" {
|
|
|
|
|
|
c.Scout.CrawlerToken = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("SEED_ADMIN_PASSWORD"); v != "" {
|
|
|
|
|
|
c.Seed.AdminPassword = v
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("WORKER_ID"); v != "" {
|
|
|
|
|
|
c.Worker.ID = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("PORT"); v != "" {
|
|
|
|
|
|
var p int
|
2026-07-15 15:23:59 +00:00
|
|
|
|
valid := true
|
2026-07-10 12:54:45 +00:00
|
|
|
|
for _, ch := range v {
|
|
|
|
|
|
if ch < '0' || ch > '9' {
|
2026-07-15 15:23:59 +00:00
|
|
|
|
valid = false
|
|
|
|
|
|
break
|
2026-07-10 12:54:45 +00:00
|
|
|
|
}
|
|
|
|
|
|
p = p*10 + int(ch-'0')
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if valid && p > 0 {
|
2026-07-10 12:54:45 +00:00
|
|
|
|
c.Port = p
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Mail / SMTP(MAIL_* 優先;HAIXUN_SMTP_* 與 old/infra 相容)
|
|
|
|
|
|
if v := firstEnv("MAIL_SENDER", "HAIXUN_SMTP_FROM"); v != "" {
|
|
|
|
|
|
c.Mail.Sender = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("MAIL_SMTP_HOST", "HAIXUN_SMTP_HOST"); v != "" {
|
|
|
|
|
|
c.Mail.SMTP.Host = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("MAIL_SMTP_USER", "HAIXUN_SMTP_USERNAME"); v != "" {
|
|
|
|
|
|
c.Mail.SMTP.User = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("MAIL_SMTP_PASSWORD", "HAIXUN_SMTP_PASSWORD"); v != "" {
|
|
|
|
|
|
c.Mail.SMTP.Password = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("MAIL_SMTP_PORT", "HAIXUN_SMTP_PORT"); v != "" {
|
|
|
|
|
|
if p := parsePort(v); p > 0 {
|
|
|
|
|
|
c.Mail.SMTP.Port = p
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("MAIL_DEV_EXPOSE_CODE"); v == "0" || v == "false" || v == "FALSE" {
|
|
|
|
|
|
c.Mail.DevExposeCode = false
|
|
|
|
|
|
} else if v == "1" || v == "true" || v == "TRUE" {
|
|
|
|
|
|
c.Mail.DevExposeCode = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("PUBLIC_WEB_BASE", "HAIXUN_PUBLIC_WEB_BASE"); v != "" {
|
|
|
|
|
|
c.PublicWebBase = strings.TrimRight(v, "/")
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
|
|
|
|
|
|
c.PublicAPIBase = strings.TrimRight(v, "/")
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
|
|
|
|
|
|
c.CORSAllowedOrigins = appendUnique(c.CORSAllowedOrigins, splitCSV(v))
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
if v := os.Getenv("MAIL_BRAND_NAME"); v != "" {
|
|
|
|
|
|
c.Brand.Name = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("MAIL_LOGO_URL", "HAIXUN_MAIL_LOGO_URL"); v != "" {
|
|
|
|
|
|
c.Brand.LogoURL = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("MAIL_COPYRIGHT"); v != "" {
|
|
|
|
|
|
c.Brand.Copyright = v
|
|
|
|
|
|
}
|
|
|
|
|
|
// Object storage (HAIXUN_STORAGE_S3_* 與 old/infra 相容)
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_ENDPOINT", "HAIXUN_STORAGE_S3_ENDPOINT"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.Endpoint = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_REGION", "HAIXUN_STORAGE_S3_REGION"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.Region = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_BUCKET", "HAIXUN_STORAGE_S3_BUCKET"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.Bucket = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_ACCESS_KEY", "HAIXUN_STORAGE_S3_ACCESS_KEY", "MINIO_ROOT_USER"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.AccessKey = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_SECRET_KEY", "HAIXUN_STORAGE_S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.SecretKey = v
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := firstEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", "HAIXUN_STORAGE_S3_PUBLIC_BASE_URL"); v != "" {
|
|
|
|
|
|
c.ObjectStorage.PublicBaseURL = strings.TrimRight(v, "/")
|
|
|
|
|
|
}
|
|
|
|
|
|
if v := os.Getenv("HAIXUN_STORAGE_S3_USE_PATH_STYLE"); v == "0" || v == "false" {
|
|
|
|
|
|
c.ObjectStorage.UsePathStyle = false
|
|
|
|
|
|
} else if v == "1" || v == "true" {
|
|
|
|
|
|
c.ObjectStorage.UsePathStyle = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
// ValidateProductionDependencies prevents a successful-looking deployment that
|
|
|
|
|
|
// silently falls back to test credentials or fake external integrations.
|
|
|
|
|
|
func (c Config) ValidateProductionDependencies() error {
|
|
|
|
|
|
if invalidSecret(c.Auth.AccessSecret) {
|
|
|
|
|
|
return fmt.Errorf("AUTH_ACCESS_SECRET must be at least 32 bytes")
|
|
|
|
|
|
}
|
|
|
|
|
|
if invalidSecret(c.Auth.RefreshSecret) {
|
|
|
|
|
|
return fmt.Errorf("AUTH_REFRESH_SECRET must be at least 32 bytes")
|
|
|
|
|
|
}
|
|
|
|
|
|
if c.Auth.AccessSecret == c.Auth.RefreshSecret {
|
|
|
|
|
|
return fmt.Errorf("AUTH_ACCESS_SECRET and AUTH_REFRESH_SECRET must differ")
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
fingerprintSecret := strings.TrimSpace(c.Redis.AIModelCacheFingerprintSecret)
|
|
|
|
|
|
if invalidSecret(fingerprintSecret) {
|
|
|
|
|
|
return fmt.Errorf("AI_MODEL_CACHE_FINGERPRINT_SECRET must be at least 32 bytes")
|
|
|
|
|
|
}
|
|
|
|
|
|
if fingerprintSecret == strings.TrimSpace(c.Auth.AccessSecret) ||
|
|
|
|
|
|
fingerprintSecret == strings.TrimSpace(c.Auth.RefreshSecret) ||
|
|
|
|
|
|
fingerprintSecret == strings.TrimSpace(c.Scout.SessionSecret) ||
|
|
|
|
|
|
fingerprintSecret == strings.TrimSpace(c.MemberSettingsEncryption.Key) {
|
|
|
|
|
|
return fmt.Errorf("AI model cache fingerprint secret must be dedicated")
|
|
|
|
|
|
}
|
|
|
|
|
|
if namespace := strings.TrimSpace(c.Redis.Namespace); namespace == "" || strings.ContainsAny(namespace, "{} \t\r\n") {
|
|
|
|
|
|
return fmt.Errorf("Redis.Namespace must be non-empty and must not contain whitespace or braces")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := c.NewMemberSettingsCodec(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("member settings encryption: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
encryptionKey := strings.TrimSpace(c.MemberSettingsEncryption.Key)
|
|
|
|
|
|
if encryptionKey == strings.TrimSpace(c.Auth.AccessSecret) ||
|
|
|
|
|
|
encryptionKey == strings.TrimSpace(c.Auth.RefreshSecret) ||
|
|
|
|
|
|
encryptionKey == strings.TrimSpace(c.Scout.SessionSecret) {
|
|
|
|
|
|
return fmt.Errorf("member settings encryption key must not reuse Auth or Scout secrets")
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
if len(c.CacheRedis) == 0 || strings.TrimSpace(c.CacheRedis[0].Host) == "" {
|
|
|
|
|
|
return fmt.Errorf("CacheRedis is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(c.Mongo.URI) == "" || strings.TrimSpace(c.Mongo.Database) == "" {
|
|
|
|
|
|
return fmt.Errorf("Mongo.URI and Mongo.Database are required")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(c.CORSAllowedOrigins) == 0 {
|
|
|
|
|
|
return fmt.Errorf("CORSAllowedOrigins is required")
|
|
|
|
|
|
}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
if err := validateStripe(c.Stripe.Enabled, c.Stripe.SecretKey, c.Stripe.WebhookSecret, c.Stripe.StarterPriceID, c.Stripe.ProPriceID, c.Stripe.SuccessURL, c.Stripe.CancelURL, c.Stripe.PortalReturnURL); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-07-13 08:59:13 +00:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
// NewMemberSettingsCodec constructs the dedicated BYOK-at-rest codec.
|
|
|
|
|
|
func (c Config) NewMemberSettingsCodec() (*secretbox.Codec, error) {
|
|
|
|
|
|
return secretbox.New(c.MemberSettingsEncryption.CurrentKeyID, c.MemberSettingsEncryption.Key)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
func invalidSecret(value string) bool {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
func validateStripe(enabled bool, secretKey, webhookSecret, starterPriceID, proPriceID, successURL, cancelURL, portalReturnURL string) error {
|
|
|
|
|
|
if !enabled {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasPrefix(strings.TrimSpace(secretKey), "sk_") {
|
|
|
|
|
|
return fmt.Errorf("STRIPE_SECRET_KEY is required when Stripe is enabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasPrefix(strings.TrimSpace(webhookSecret), "whsec_") {
|
|
|
|
|
|
return fmt.Errorf("STRIPE_WEBHOOK_SECRET is required when Stripe is enabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
starterPriceID = strings.TrimSpace(starterPriceID)
|
|
|
|
|
|
proPriceID = strings.TrimSpace(proPriceID)
|
|
|
|
|
|
if !strings.HasPrefix(starterPriceID, "price_") || !strings.HasPrefix(proPriceID, "price_") {
|
|
|
|
|
|
return fmt.Errorf("STRIPE_STARTER_PRICE_ID and STRIPE_PRO_PRICE_ID are required when Stripe is enabled")
|
|
|
|
|
|
}
|
|
|
|
|
|
if starterPriceID == proPriceID {
|
|
|
|
|
|
return fmt.Errorf("Stripe Starter and Pro Price IDs must differ")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(successURL, "{CHECKOUT_ID}") {
|
|
|
|
|
|
return fmt.Errorf("STRIPE_SUCCESS_URL must contain {CHECKOUT_ID}")
|
|
|
|
|
|
}
|
|
|
|
|
|
for name, value := range map[string]string{
|
|
|
|
|
|
"STRIPE_SUCCESS_URL": successURL,
|
|
|
|
|
|
"STRIPE_CANCEL_URL": cancelURL,
|
|
|
|
|
|
"STRIPE_PORTAL_RETURN_URL": portalReturnURL,
|
|
|
|
|
|
} {
|
|
|
|
|
|
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
|
|
|
|
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
|
|
|
|
|
return fmt.Errorf("%s must be an absolute HTTPS URL", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:54:45 +00:00
|
|
|
|
func firstEnv(keys ...string) string {
|
|
|
|
|
|
for _, k := range keys {
|
|
|
|
|
|
if v := os.Getenv(k); v != "" {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
func appendUnique(existing, values []string) []string {
|
|
|
|
|
|
seen := make(map[string]struct{}, len(existing)+len(values))
|
|
|
|
|
|
out := make([]string, 0, len(existing)+len(values))
|
|
|
|
|
|
for _, value := range append(existing, values...) {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := seen[value]; ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[value] = struct{}{}
|
|
|
|
|
|
out = append(out, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func splitCSV(v string) []string {
|
|
|
|
|
|
parts := strings.Split(v, ",")
|
|
|
|
|
|
out := make([]string, 0, len(parts))
|
|
|
|
|
|
for _, part := range parts {
|
|
|
|
|
|
if part = strings.TrimSpace(part); part != "" {
|
|
|
|
|
|
out = append(out, strings.TrimRight(part, "/"))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:54:45 +00:00
|
|
|
|
func parsePort(v string) int {
|
|
|
|
|
|
var p int
|
|
|
|
|
|
for _, ch := range v {
|
|
|
|
|
|
if ch < '0' || ch > '9' {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
p = p*10 + int(ch-'0')
|
|
|
|
|
|
}
|
|
|
|
|
|
return p
|
|
|
|
|
|
}
|