Files
story/internal/config/config.go
T

747 lines
30 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
package config
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
EnvironmentLocal = "local"
EnvironmentTest = "test"
EnvironmentDev = "dev"
EnvironmentStage = "stage"
EnvironmentProd = "prod"
)
type Config struct {
ServiceName string
Environment string
LogLevel string
HTTPAddr string
DatabaseURL string
RedisAddr string
RedisPassword string
RedisDB int
AuthIssuerURL string
AuthJWKSURL string
AuthAudience string
OTLPEndpoint string
RateLimitRPS float64
RateLimitBurst int
RateLimitUserRPS float64
RateLimitUserBurst int
RateLimitTenantRPS float64
RateLimitTenantBurst int
RateLimitDeviceRPS float64
RateLimitDeviceBurst int
ProviderStartRPS float64
ProviderStartBurst int
ProviderMaxActiveTasks int
DeviceLocalSecret string
DeviceSecretDirectory string
DeviceClockSkew time.Duration
RTCAppID string
RTCAppKey string
RTCTokenIssuer string
RTCTokenSecret string
CursorSigningSecret string
ShareTokenSecret string
RTCTokenTTL time.Duration
AllocationTTL time.Duration
HeartbeatInterval int
ProviderCallbackSecret string
ProviderCallbackSkew time.Duration
MockProviderBaseURL string
MockProviderAPIKey string
VoiceProvider string
VolcRTCBaseURL string
VolcAccessKeyID string
VolcSecretAccessKey string
VolcSessionToken string
VolcRTCRegion string
VolcRTCAPIVersion string
VolcVoiceChatTemplate string
ProviderRequestTimeout time.Duration
ProviderFailureThreshold int
ProviderCircuitCooldown time.Duration
DependencyTimeout time.Duration
ExternalProbeInterval time.Duration
ShutdownTimeout time.Duration
WorkerPollInterval time.Duration
RecoveryScanInterval time.Duration
RecoveryStartingAfter time.Duration
RecoveryStoppingAfter time.Duration
ObjectStoreEndpoint string
ObjectStorePublic string
ObjectStoreAccessKey string
ObjectStoreSecretKey string
ObjectStoreBucket string
ObjectStoreRegion string
ObjectStoreSecure bool
ExportDownloadTTL time.Duration
ExportObjectTTL time.Duration
MediaLocalDeleteGrace time.Duration
AlertWebhookURL string
AlertWebhookSecret string
AlertWebhookTimeout time.Duration
ContentGenerator string
ArkBaseURL string
ArkAPIKey string
ArkContentModel string
ArkRequestTimeout time.Duration
ContentSafetyProvider string
ContentSafetyEndpoint string
ContentSafetyAPIKey string
ContentSafetyPolicyRef string
ContentSafetyTimeout time.Duration
EmbeddingProvider string
ArkEmbeddingModel string
}
func Load(serviceName string) (Config, error) {
if strings.TrimSpace(serviceName) == "" {
return Config{}, errors.New("service name is required")
}
redisDB, err := intEnv("REDIS_DB", 0)
if err != nil {
return Config{}, err
}
dependencyTimeout, err := durationEnv("DEPENDENCY_TIMEOUT", 2*time.Second)
if err != nil {
return Config{}, err
}
externalProbeInterval, err := durationEnv("EXTERNAL_PROBE_INTERVAL", 30*time.Second)
if err != nil {
return Config{}, err
}
shutdownTimeout, err := durationEnv("SHUTDOWN_TIMEOUT", 10*time.Second)
if err != nil {
return Config{}, err
}
pollInterval, err := durationEnv("WORKER_POLL_INTERVAL", 2*time.Second)
if err != nil {
return Config{}, err
}
recoveryScanInterval, err := durationEnv("RECOVERY_SCAN_INTERVAL", 5*time.Second)
if err != nil {
return Config{}, err
}
recoveryStartingAfter, err := durationEnv("RECOVERY_STARTING_AFTER", 20*time.Second)
if err != nil {
return Config{}, err
}
recoveryStoppingAfter, err := durationEnv("RECOVERY_STOPPING_AFTER", 20*time.Second)
if err != nil {
return Config{}, err
}
deviceClockSkew, err := durationEnv("DEVICE_CLOCK_SKEW", 5*time.Minute)
if err != nil {
return Config{}, err
}
rtcTokenTTL, err := durationEnv("RTC_TOKEN_TTL", 5*time.Minute)
if err != nil {
return Config{}, err
}
allocationTTL, err := durationEnv("CONVERSATION_ALLOCATION_TTL", 30*time.Second)
if err != nil {
return Config{}, err
}
providerCallbackSkew, err := durationEnv("PROVIDER_CALLBACK_CLOCK_SKEW", 5*time.Minute)
if err != nil {
return Config{}, err
}
providerRequestTimeout, err := durationEnv("PROVIDER_REQUEST_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
providerCircuitCooldown, err := durationEnv("PROVIDER_CIRCUIT_COOLDOWN", time.Minute)
if err != nil {
return Config{}, err
}
exportDownloadTTL, err := durationEnv("EXPORT_DOWNLOAD_TTL", 10*time.Minute)
if err != nil {
return Config{}, err
}
exportObjectTTL, err := durationEnv("EXPORT_OBJECT_TTL", 24*time.Hour)
if err != nil {
return Config{}, err
}
mediaLocalDeleteGrace, err := durationEnv("MEDIA_LOCAL_DELETE_GRACE", time.Hour)
if err != nil {
return Config{}, err
}
alertWebhookTimeout, err := durationEnv("ALERT_WEBHOOK_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
arkRequestTimeout, err := durationEnv("ARK_REQUEST_TIMEOUT", 30*time.Second)
if err != nil {
return Config{}, err
}
contentSafetyTimeout, err := durationEnv("CONTENT_SAFETY_TIMEOUT", 5*time.Second)
if err != nil {
return Config{}, err
}
objectStoreSecure, err := boolEnv("OBJECT_STORE_SECURE", false)
if err != nil {
return Config{}, err
}
environment := stringEnv("APP_ENV", EnvironmentLocal)
secretValues, err := loadSecretValues(
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET",
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY",
)
if err != nil {
return Config{}, err
}
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
providerCallbackSecret := secretValues["PROVIDER_CALLBACK_SECRET"]
objectStoreEndpoint := strings.TrimSpace(os.Getenv("OBJECT_STORE_ENDPOINT"))
objectStorePublic := strings.TrimSpace(os.Getenv("OBJECT_STORE_PUBLIC_ENDPOINT"))
objectStoreAccessKey := secretValues["OBJECT_STORE_ACCESS_KEY"]
objectStoreSecretKey := secretValues["OBJECT_STORE_SECRET_KEY"]
objectStoreBucket := strings.TrimSpace(os.Getenv("OBJECT_STORE_EXPORT_BUCKET"))
objectStoreRegion := strings.TrimSpace(os.Getenv("OBJECT_STORE_REGION"))
if deviceLocalSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
deviceLocalSecret = "local-simulated-device-secret-32-bytes-minimum"
}
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
}
if cursorSigningSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
cursorSigningSecret = "local-signed-pagination-cursor-secret-32-bytes-minimum"
}
if shareTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
shareTokenSecret = "local-share-token-signing-secret-32-bytes-minimum"
}
if providerCallbackSecret == "" && serviceName == "callbacks" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
providerCallbackSecret = "local-provider-callback-secret-32-bytes-minimum"
}
if (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
if objectStoreEndpoint == "" {
objectStoreEndpoint = "localhost:59000"
}
if objectStorePublic == "" {
objectStorePublic = "localhost:59000"
}
if objectStoreAccessKey == "" {
objectStoreAccessKey = "local_minio"
}
if objectStoreSecretKey == "" {
objectStoreSecretKey = "local_minio_password"
}
if objectStoreBucket == "" {
objectStoreBucket = "private-exports"
}
if objectStoreRegion == "" {
objectStoreRegion = "us-east-1"
}
}
cfg := Config{
ServiceName: serviceName,
Environment: environment,
LogLevel: stringEnv("LOG_LEVEL", "info"),
HTTPAddr: stringEnv("HTTP_ADDR", defaultHTTPAddr(serviceName)),
DatabaseURL: secretDefault(secretValues["DATABASE_URL"], "postgres://voice:voice_dev_only@localhost:55432/voice_platform?sslmode=disable"),
RedisAddr: stringEnv("REDIS_ADDR", "localhost:56379"),
RedisPassword: secretValues["REDIS_PASSWORD"],
RedisDB: redisDB,
AuthIssuerURL: stringEnv("AUTH_ISSUER_URL", "http://localhost:58180/realms/voice-local"),
AuthJWKSURL: stringEnv("AUTH_JWKS_URL", "http://localhost:58180/realms/voice-local/protocol/openid-connect/certs"),
AuthAudience: stringEnv("AUTH_AUDIENCE", "voice-platform-web"),
OTLPEndpoint: stringEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:54317"),
DependencyTimeout: dependencyTimeout,
ExternalProbeInterval: externalProbeInterval,
ShutdownTimeout: shutdownTimeout,
WorkerPollInterval: pollInterval,
RecoveryScanInterval: recoveryScanInterval,
RecoveryStartingAfter: recoveryStartingAfter,
RecoveryStoppingAfter: recoveryStoppingAfter,
DeviceLocalSecret: deviceLocalSecret,
DeviceSecretDirectory: strings.TrimSpace(os.Getenv("DEVICE_SECRET_DIRECTORY")),
DeviceClockSkew: deviceClockSkew,
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
RTCAppKey: secretValues["RTC_APP_KEY"],
RTCTokenIssuer: stringEnv("RTC_TOKEN_ISSUER", "local"),
RTCTokenSecret: rtcTokenSecret,
CursorSigningSecret: cursorSigningSecret,
ShareTokenSecret: shareTokenSecret,
RTCTokenTTL: rtcTokenTTL,
AllocationTTL: allocationTTL,
ProviderCallbackSecret: providerCallbackSecret,
ProviderCallbackSkew: providerCallbackSkew,
MockProviderBaseURL: stringEnv("MOCK_PROVIDER_BASE_URL", "http://localhost:59200"),
MockProviderAPIKey: secretDefault(secretValues["MOCK_PROVIDER_API_KEY"], "local-mock-provider-key"),
VoiceProvider: stringEnv("VOICE_PROVIDER", "mock"),
VolcRTCBaseURL: stringEnv("VOLC_RTC_BASE_URL", "https://rtc.volcengineapi.com"),
VolcAccessKeyID: secretValues["VOLC_ACCESS_KEY_ID"],
VolcSecretAccessKey: secretValues["VOLC_SECRET_ACCESS_KEY"],
VolcSessionToken: secretValues["VOLC_SESSION_TOKEN"],
VolcRTCRegion: stringEnv("VOLC_RTC_REGION", "cn-north-1"),
VolcRTCAPIVersion: stringEnv("VOLC_RTC_API_VERSION", "2024-12-01"),
VolcVoiceChatTemplate: strings.TrimSpace(os.Getenv("VOLC_VOICE_CHAT_TEMPLATE_FILE")),
ProviderRequestTimeout: providerRequestTimeout,
ProviderCircuitCooldown: providerCircuitCooldown,
ObjectStoreEndpoint: objectStoreEndpoint,
ObjectStorePublic: objectStorePublic,
ObjectStoreAccessKey: objectStoreAccessKey,
ObjectStoreSecretKey: objectStoreSecretKey,
ObjectStoreBucket: objectStoreBucket,
ObjectStoreRegion: objectStoreRegion,
ObjectStoreSecure: objectStoreSecure,
ExportDownloadTTL: exportDownloadTTL,
ExportObjectTTL: exportObjectTTL,
MediaLocalDeleteGrace: mediaLocalDeleteGrace,
AlertWebhookURL: strings.TrimSpace(os.Getenv("ALERT_WEBHOOK_URL")),
AlertWebhookSecret: secretValues["ALERT_WEBHOOK_SECRET"],
AlertWebhookTimeout: alertWebhookTimeout,
ContentGenerator: stringEnv("CONTENT_GENERATOR", "local"),
ArkBaseURL: stringEnv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
ArkAPIKey: secretValues["ARK_API_KEY"],
ArkContentModel: strings.TrimSpace(os.Getenv("ARK_CONTENT_MODEL")),
ArkRequestTimeout: arkRequestTimeout,
ContentSafetyProvider: stringEnv("CONTENT_SAFETY_PROVIDER", "local"),
ContentSafetyEndpoint: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_ENDPOINT")),
ContentSafetyAPIKey: secretValues["CONTENT_SAFETY_API_KEY"],
ContentSafetyPolicyRef: strings.TrimSpace(os.Getenv("CONTENT_SAFETY_POLICY_REF")),
ContentSafetyTimeout: contentSafetyTimeout,
EmbeddingProvider: stringEnv("EMBEDDING_PROVIDER", "local"),
ArkEmbeddingModel: strings.TrimSpace(os.Getenv("ARK_EMBEDDING_MODEL")),
}
cfg.RateLimitRPS, err = floatEnv("RATE_LIMIT_RPS", 50)
if err != nil {
return Config{}, err
}
cfg.RateLimitBurst, err = intEnv("RATE_LIMIT_BURST", 100)
if err != nil {
return Config{}, err
}
cfg.RateLimitUserRPS, err = floatEnv("RATE_LIMIT_USER_RPS", 20)
if err != nil {
return Config{}, err
}
cfg.RateLimitUserBurst, err = intEnv("RATE_LIMIT_USER_BURST", 40)
if err != nil {
return Config{}, err
}
cfg.RateLimitTenantRPS, err = floatEnv("RATE_LIMIT_TENANT_RPS", 200)
if err != nil {
return Config{}, err
}
cfg.RateLimitTenantBurst, err = intEnv("RATE_LIMIT_TENANT_BURST", 400)
if err != nil {
return Config{}, err
}
cfg.RateLimitDeviceRPS, err = floatEnv("RATE_LIMIT_DEVICE_RPS", 10)
if err != nil {
return Config{}, err
}
cfg.RateLimitDeviceBurst, err = intEnv("RATE_LIMIT_DEVICE_BURST", 20)
if err != nil {
return Config{}, err
}
cfg.ProviderStartRPS, err = floatEnv("PROVIDER_START_RPS", 20)
if err != nil {
return Config{}, err
}
cfg.ProviderStartBurst, err = intEnv("PROVIDER_START_BURST", 40)
if err != nil {
return Config{}, err
}
cfg.ProviderMaxActiveTasks, err = intEnv("PROVIDER_MAX_ACTIVE_TASKS", 100)
if err != nil {
return Config{}, err
}
cfg.HeartbeatInterval, err = intEnv("HEARTBEAT_INTERVAL_SECONDS", 15)
if err != nil {
return Config{}, err
}
cfg.ProviderFailureThreshold, err = intEnv("PROVIDER_FAILURE_THRESHOLD", 3)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
validEnvironment := map[string]bool{
EnvironmentLocal: true,
EnvironmentTest: true,
EnvironmentDev: true,
EnvironmentStage: true,
EnvironmentProd: true,
}
if !validEnvironment[c.Environment] {
return fmt.Errorf("APP_ENV %q is not supported", c.Environment)
}
if strings.TrimSpace(c.HTTPAddr) == "" {
return errors.New("HTTP_ADDR is required")
}
if strings.TrimSpace(c.DatabaseURL) == "" {
return errors.New("DATABASE_URL is required")
}
if strings.TrimSpace(c.RedisAddr) == "" {
return errors.New("REDIS_ADDR is required")
}
if strings.TrimSpace(c.AuthIssuerURL) == "" || strings.TrimSpace(c.AuthJWKSURL) == "" || strings.TrimSpace(c.AuthAudience) == "" {
return errors.New("AUTH_ISSUER_URL, AUTH_JWKS_URL and AUTH_AUDIENCE are required")
}
if c.DependencyTimeout <= 0 || c.ExternalProbeInterval <= 0 || c.ShutdownTimeout <= 0 || c.WorkerPollInterval <= 0 ||
c.RecoveryScanInterval <= 0 || c.RecoveryStartingAfter <= 0 || c.RecoveryStoppingAfter <= 0 {
return errors.New("timeouts and worker poll interval must be positive")
}
if c.RateLimitRPS <= 0 || c.RateLimitBurst <= 0 ||
c.RateLimitUserRPS <= 0 || c.RateLimitUserBurst <= 0 ||
c.RateLimitTenantRPS <= 0 || c.RateLimitTenantBurst <= 0 ||
c.RateLimitDeviceRPS <= 0 || c.RateLimitDeviceBurst <= 0 ||
c.ProviderStartRPS <= 0 || c.ProviderStartBurst <= 0 || c.ProviderMaxActiveTasks <= 0 {
return errors.New("rate limits, bursts and provider task capacity must be positive")
}
if c.ServiceName == "api" && c.DeviceSecretDirectory == "" && len(c.DeviceLocalSecret) < 32 {
return errors.New("DEVICE_LOCAL_SECRET must contain at least 32 characters when DEVICE_SECRET_DIRECTORY is not configured")
}
if c.ServiceName == "api" && c.DeviceSecretDirectory != "" && !filepath.IsAbs(c.DeviceSecretDirectory) {
return errors.New("DEVICE_SECRET_DIRECTORY must be an absolute path")
}
if c.DeviceClockSkew <= 0 {
return errors.New("DEVICE_CLOCK_SKEW must be positive")
}
if c.ServiceName == "api" {
if c.RTCTokenIssuer != "local" && c.RTCTokenIssuer != "volcengine" {
return errors.New("RTC_TOKEN_ISSUER must be local or volcengine")
}
if c.RTCTokenIssuer == "local" && len(c.RTCTokenSecret) < 32 {
return errors.New("RTC_TOKEN_SIGNING_SECRET must contain at least 32 characters for the local issuer")
}
if c.RTCTokenIssuer == "volcengine" && len(c.RTCAppKey) < 16 {
return errors.New("RTC_APP_KEY must contain at least 16 characters for the Volcengine token issuer")
}
if c.RTCTokenIssuer == "volcengine" && len(c.RTCAppID) != 24 {
return errors.New("RTC_APP_ID must contain exactly 24 characters for the Volcengine token issuer")
}
}
if c.ServiceName == "api" && len(c.CursorSigningSecret) < 32 {
return errors.New("CURSOR_SIGNING_SECRET must contain at least 32 characters")
}
if c.ServiceName == "api" && len(c.ShareTokenSecret) < 32 {
return errors.New("SHARE_TOKEN_SIGNING_SECRET must contain at least 32 characters")
}
if c.RTCAppID == "" || c.RTCTokenTTL <= 0 || c.AllocationTTL <= 0 || c.HeartbeatInterval <= 0 {
return errors.New("RTC app, token TTL, allocation TTL and heartbeat interval must be valid")
}
if c.ServiceName == "callbacks" && len(c.ProviderCallbackSecret) < 32 {
return errors.New("PROVIDER_CALLBACK_SECRET must contain at least 32 characters")
}
if c.ServiceName == "worker" {
if c.VoiceProvider != "mock" && c.VoiceProvider != "volcengine" {
return errors.New("VOICE_PROVIDER must be mock or volcengine")
}
if c.VoiceProvider == "mock" && (c.MockProviderBaseURL == "" || c.MockProviderAPIKey == "") {
return errors.New("MOCK_PROVIDER_BASE_URL and MOCK_PROVIDER_API_KEY are required for the mock voice provider")
}
if c.VoiceProvider == "volcengine" {
if !isAbsoluteHTTPURL(c.VolcRTCBaseURL) || c.VolcAccessKeyID == "" || c.VolcSecretAccessKey == "" || c.VolcRTCRegion == "" || c.VolcRTCAPIVersion == "" || c.VolcVoiceChatTemplate == "" {
return errors.New("Volcengine voice provider requires an RTC URL, AK/SK, region, API version and voice-chat template file")
}
}
}
if c.ProviderCallbackSkew <= 0 || c.ProviderRequestTimeout <= 0 || c.ProviderFailureThreshold < 1 || c.ProviderCircuitCooldown <= 0 {
return errors.New("provider callback skew and request timeout must be positive")
}
if c.AlertWebhookTimeout <= 0 {
return errors.New("ALERT_WEBHOOK_TIMEOUT must be positive")
}
if c.ArkRequestTimeout <= 0 {
return errors.New("ARK_REQUEST_TIMEOUT must be positive")
}
if c.ContentSafetyTimeout <= 0 {
return errors.New("CONTENT_SAFETY_TIMEOUT must be positive")
}
if c.ServiceName == "worker" && c.AlertWebhookURL != "" {
parsed, err := url.Parse(c.AlertWebhookURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("ALERT_WEBHOOK_URL must be an absolute HTTP(S) URL")
}
if len(c.AlertWebhookSecret) < 32 {
return errors.New("ALERT_WEBHOOK_SECRET must contain at least 32 characters when webhook delivery is enabled")
}
}
if c.ServiceName == "worker" {
if c.ContentSafetyProvider != "local" && c.ContentSafetyProvider != "managed_http" {
return errors.New("CONTENT_SAFETY_PROVIDER must be local or managed_http")
}
if c.ContentSafetyProvider == "managed_http" {
if !isAbsoluteHTTPURL(c.ContentSafetyEndpoint) || c.ContentSafetyAPIKey == "" || c.ContentSafetyPolicyRef == "" {
return errors.New("managed content safety requires an HTTP(S) endpoint, API key and policy reference")
}
}
if c.ContentGenerator != "local" && c.ContentGenerator != "ark" {
return errors.New("CONTENT_GENERATOR must be local or ark")
}
if c.ContentGenerator == "ark" {
parsed, err := url.Parse(c.ArkBaseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("ARK_BASE_URL must be an absolute HTTP(S) URL")
}
if c.ArkAPIKey == "" || c.ArkContentModel == "" {
return errors.New("ARK_API_KEY and ARK_CONTENT_MODEL are required for the Ark content generator")
}
}
}
if c.ServiceName == "api" || c.ServiceName == "worker" {
if c.EmbeddingProvider != "local" && c.EmbeddingProvider != "ark" {
return errors.New("EMBEDDING_PROVIDER must be local or ark")
}
if c.EmbeddingProvider == "ark" {
parsed, err := url.Parse(c.ArkBaseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return errors.New("ARK_BASE_URL must be an absolute HTTP(S) URL")
}
if c.ArkAPIKey == "" || c.ArkEmbeddingModel == "" {
return errors.New("ARK_API_KEY and ARK_EMBEDDING_MODEL are required for the Ark embedding provider")
}
}
}
if c.ServiceName == "api" || c.ServiceName == "worker" {
if c.ObjectStoreEndpoint == "" || c.ObjectStorePublic == "" || c.ObjectStoreAccessKey == "" || c.ObjectStoreSecretKey == "" || c.ObjectStoreBucket == "" || c.ObjectStoreRegion == "" {
return errors.New("object store endpoint, public endpoint, credentials, region and export bucket are required")
}
if c.ExportDownloadTTL <= 0 || c.ExportDownloadTTL > 24*time.Hour || c.ExportObjectTTL <= 0 {
return errors.New("export download and object TTL must be positive, and download TTL cannot exceed 24 hours")
}
if c.MediaLocalDeleteGrace <= 0 || c.MediaLocalDeleteGrace > 7*24*time.Hour {
return errors.New("media local delete grace must be positive and cannot exceed 7 days")
}
}
if c.Environment == EnvironmentProd {
if err := c.validateProduction(); err != nil {
return err
}
}
return nil
}
func (c Config) validateProduction() error {
if !isHTTPSURL(c.AuthIssuerURL) || !isHTTPSURL(c.AuthJWKSURL) {
return errors.New("production OIDC issuer and JWKS URLs must use HTTPS")
}
databaseURL, err := url.Parse(c.DatabaseURL)
if err != nil || databaseURL.Scheme == "" || strings.EqualFold(databaseURL.Query().Get("sslmode"), "disable") {
return errors.New("production DATABASE_URL must enable transport security")
}
if strings.TrimSpace(c.RedisPassword) == "" || isPlaceholderSecret(c.RedisPassword) {
return errors.New("production REDIS_PASSWORD must be a non-placeholder secret")
}
if c.ServiceName == "api" {
if c.DeviceSecretDirectory == "" {
return errors.New("production API must use DEVICE_SECRET_DIRECTORY for per-device credentials")
}
if isPlaceholderSecret(c.CursorSigningSecret) || isPlaceholderSecret(c.ShareTokenSecret) {
return errors.New("production API signing secrets must not use local or example values")
}
if c.CursorSigningSecret == c.ShareTokenSecret {
return errors.New("production API signing secrets must be distinct")
}
if isLocalValue(c.RTCAppID) || c.RTCTokenIssuer != "volcengine" || isPlaceholderSecret(c.RTCAppKey) {
return errors.New("production RTC credentials must use a real app ID, Volcengine token issuer and non-placeholder app key")
}
if c.EmbeddingProvider != "ark" || !isHTTPSURL(c.ArkBaseURL) || isPlaceholderSecret(c.ArkAPIKey) || isLocalValue(c.ArkEmbeddingModel) {
return errors.New("production memory retrieval must use Ark HTTPS, a non-placeholder API key and a real embedding model ID")
}
}
if c.ServiceName == "callbacks" && isPlaceholderSecret(c.ProviderCallbackSecret) {
return errors.New("production PROVIDER_CALLBACK_SECRET must not use a local or example value")
}
if c.ServiceName == "worker" {
if c.VoiceProvider != "volcengine" || len(c.RTCAppID) != 24 || isLocalValue(c.RTCAppID) || !isHTTPSURL(c.VolcRTCBaseURL) || isLocalValue(c.VolcRTCBaseURL) || isPlaceholderSecret(c.VolcAccessKeyID) || isPlaceholderSecret(c.VolcSecretAccessKey) {
return errors.New("production voice provider must use signed Volcengine RTC HTTPS with non-placeholder AK/SK")
}
if c.AlertWebhookURL != "" && (!isHTTPSURL(c.AlertWebhookURL) || isPlaceholderSecret(c.AlertWebhookSecret)) {
return errors.New("production alert webhook must use HTTPS and a non-placeholder signing secret")
}
if c.ContentGenerator != "ark" || !isHTTPSURL(c.ArkBaseURL) || isPlaceholderSecret(c.ArkAPIKey) || isLocalValue(c.ArkContentModel) {
return errors.New("production content generation must use Ark HTTPS, a non-placeholder API key and a real model ID")
}
if c.ContentSafetyProvider != "managed_http" || !isHTTPSURL(c.ContentSafetyEndpoint) || isLocalValue(c.ContentSafetyEndpoint) || isPlaceholderSecret(c.ContentSafetyAPIKey) || isLocalValue(c.ContentSafetyPolicyRef) {
return errors.New("production content safety must use managed HTTPS with a non-placeholder API key and policy reference")
}
if c.EmbeddingProvider != "ark" || isLocalValue(c.ArkEmbeddingModel) {
return errors.New("production memory indexing must use a real Ark embedding model ID")
}
}
if c.ServiceName == "api" || c.ServiceName == "worker" {
if !c.ObjectStoreSecure {
return errors.New("production object storage must use TLS")
}
if isLocalValue(c.ObjectStoreEndpoint) || isLocalValue(c.ObjectStorePublic) {
return errors.New("production object storage endpoints must not target localhost")
}
if isPlaceholderSecret(c.ObjectStoreAccessKey) || isPlaceholderSecret(c.ObjectStoreSecretKey) {
return errors.New("production object storage credentials must not use local or example values")
}
}
return nil
}
func isHTTPSURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil && strings.EqualFold(parsed.Scheme, "https") && parsed.Host != ""
}
func isAbsoluteHTTPURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) && parsed.Host != ""
}
func isLocalValue(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
allZeroes := normalized != "" && strings.Trim(normalized, "0") == ""
return strings.Contains(normalized, "localhost") || strings.Contains(normalized, "127.0.0.1") ||
strings.Contains(normalized, "mock-provider") || strings.Contains(normalized, "example.invalid") ||
strings.Contains(normalized, "replace-with") || strings.HasPrefix(normalized, "local-") ||
strings.HasPrefix(normalized, "local_") || allZeroes
}
func isPlaceholderSecret(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return len(normalized) < 32 || strings.Contains(normalized, "local-") || strings.Contains(normalized, "local_") || strings.Contains(normalized, "dev_only") || strings.Contains(normalized, "changeme") || strings.Contains(normalized, "example")
}
func loadSecretValues(names ...string) (map[string]string, error) {
values := make(map[string]string, len(names))
for _, name := range names {
value, err := secretEnv(name)
if err != nil {
return nil, err
}
values[name] = value
}
return values, nil
}
func secretEnv(name string) (string, error) {
inline := strings.TrimSpace(os.Getenv(name))
filePath := strings.TrimSpace(os.Getenv(name + "_FILE"))
if inline != "" && filePath != "" {
return "", fmt.Errorf("%s and %s_FILE cannot both be configured", name, name)
}
if filePath == "" {
return inline, nil
}
if !filepath.IsAbs(filePath) {
return "", fmt.Errorf("%s_FILE must be an absolute path", name)
}
file, err := os.Open(filepath.Clean(filePath))
if err != nil {
return "", fmt.Errorf("read %s_FILE: secret file is unavailable", name)
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
return "", fmt.Errorf("read %s_FILE: secret path must be a regular file", name)
}
content, err := io.ReadAll(io.LimitReader(file, 64*1024+1))
if err != nil || len(content) > 64*1024 {
return "", fmt.Errorf("read %s_FILE: secret file exceeds 64 KiB", name)
}
content = bytes.TrimRight(content, "\r\n")
if bytes.IndexByte(content, 0) >= 0 {
return "", fmt.Errorf("read %s_FILE: secret contains a NUL byte", name)
}
return string(content), nil
}
func secretDefault(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func defaultHTTPAddr(serviceName string) string {
switch serviceName {
case "callbacks":
return "0.0.0.0:58081"
case "worker":
return "0.0.0.0:58082"
default:
return "0.0.0.0:58080"
}
}
func stringEnv(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func intEnv(key string, fallback int) (int, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
}
return parsed, nil
}
func floatEnv(key string, fallback float64) (float64, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("%s must be a number: %w", key, err)
}
return parsed, nil
}
func boolEnv(key string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
}
return parsed, nil
}
func durationEnv(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
}
return parsed, nil
}