Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,431 @@
package platformsettings
import (
"context"
"fmt"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
)
const (
maxAIProviderLen = 64
maxAIBaseURLLen = 512
maxAIModelLen = 128
maxAIExtrasKeys = 32
maxAIExtrasKeyLen = 64
maxAIExtrasValLen = 2048
defaultAIProvider = "openai"
)
type aiConfigStored struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
APIKeyEnc string `json:"api_key_enc"`
APIKeyLast4 string `json:"api_key_last4"`
Enabled bool `json:"enabled"`
Extras map[string]string `json:"extras,omitempty"`
}
// ValidAIRole reports whether role is a known platform AI config slot.
func ValidAIRole(role string) bool {
switch strings.TrimSpace(role) {
case AIRoleProcessing, AIRoleVectorization, AIRoleDocsAPI, AIRoleSupport:
return true
default:
return false
}
}
func (s *Service) publicAIConfigs(doc storedDoc) map[string]AIConfigPublic {
out := make(map[string]AIConfigPublic, len(AIRoles))
for _, role := range AIRoles {
st, ok := doc.AIConfigs[role]
if !ok {
st = aiConfigStored{}
}
out[role] = s.publicAIConfig(role, st, doc.OpenAI)
}
return out
}
func (s *Service) publicAIConfig(role string, st aiConfigStored, openai openaiStored) AIConfigPublic {
hasRoleData := strings.TrimSpace(st.Provider) != "" ||
strings.TrimSpace(st.BaseURL) != "" ||
strings.TrimSpace(st.Model) != "" ||
strings.TrimSpace(st.APIKeyEnc) != "" ||
st.Enabled ||
len(st.Extras) > 0
if role == AIRoleProcessing && !hasRoleData {
oi := s.publicOpenAI(openai)
return AIConfigPublic{
Role: role,
Provider: defaultAIProvider,
BaseURL: oi.BaseURL,
Model: oi.Model,
Enabled: oi.HasAPIKey,
Configured: oi.Configured,
HasAPIKey: oi.HasAPIKey,
APIKeyLast4: oi.APIKeyLast4,
APIKeyMasked: oi.APIKeyMasked,
Source: oi.Source,
}
}
hasDB := strings.TrimSpace(st.APIKeyEnc) != ""
out := AIConfigPublic{
Role: role,
Provider: strings.TrimSpace(st.Provider),
BaseURL: strings.TrimSpace(st.BaseURL),
Model: strings.TrimSpace(st.Model),
Enabled: st.Enabled,
Extras: copyStringMap(st.Extras),
Source: SourceNone,
}
if hasDB {
out.Configured = true
out.HasAPIKey = true
out.APIKeyLast4 = st.APIKeyLast4
out.APIKeyMasked = maskLast4(st.APIKeyLast4)
out.Source = SourceDB
return out
}
if out.Provider != "" || out.BaseURL != "" || out.Model != "" || out.Enabled || len(out.Extras) > 0 {
out.Configured = true
out.Source = SourceDB
return out
}
if role == AIRoleVectorization {
env := s.resolveVectorizationEnv()
if env.APIKey != "" {
out.Configured = true
out.HasAPIKey = true
out.APIKeyLast4 = last4(env.APIKey)
out.APIKeyMasked = maskLast4(out.APIKeyLast4)
out.BaseURL = env.BaseURL
out.Model = env.Model
out.Provider = defaultAIProvider
out.Enabled = true
out.Source = SourceEnv
return out
}
}
return out
}
func (s *Service) patchAIConfigs(doc *storedDoc, patches map[string]*AIConfigUpdate) error {
if doc.AIConfigs == nil {
doc.AIConfigs = map[string]aiConfigStored{}
}
for role, patch := range patches {
role = strings.TrimSpace(role)
if !ValidAIRole(role) {
return ClientMsg(fmt.Sprintf("unknown ai_roles role %q (want processing|vectorization|docs_api|support)", role))
}
if patch == nil {
continue
}
st := doc.AIConfigs[role]
if err := s.patchAIConfig(&st, *patch); err != nil {
return err
}
doc.AIConfigs[role] = st
}
return nil
}
func (s *Service) patchAIConfig(st *aiConfigStored, in AIConfigUpdate) error {
if in.Provider != nil {
p := strings.TrimSpace(*in.Provider)
if len(p) > maxAIProviderLen {
return ClientMsg(fmt.Sprintf("provider exceeds %d characters", maxAIProviderLen))
}
st.Provider = p
}
if in.BaseURL != nil {
u := strings.TrimSpace(*in.BaseURL)
if len(u) > maxAIBaseURLLen {
return ClientMsg(fmt.Sprintf("base_url exceeds %d characters", maxAIBaseURLLen))
}
if u != "" {
normalized, err := security.ValidatePublicHTTPSURL(u)
if err != nil || normalized == "" {
return ClientMsg("invalid base_url")
}
u = strings.TrimRight(normalized, "/")
}
st.BaseURL = u
}
if in.Model != nil {
m := strings.TrimSpace(*in.Model)
if len(m) > maxAIModelLen {
return ClientMsg(fmt.Sprintf("model exceeds %d characters", maxAIModelLen))
}
st.Model = m
}
if in.Enabled != nil {
st.Enabled = *in.Enabled
}
if in.Extras != nil {
if err := patchAIExtras(&st.Extras, in.Extras); err != nil {
return err
}
}
if in.ClearAPIKey {
st.APIKeyEnc = ""
st.APIKeyLast4 = ""
return nil
}
if in.APIKey != nil {
plain := strings.TrimSpace(*in.APIKey)
if plain == "" {
return nil
}
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return err
}
st.APIKeyEnc = enc
st.APIKeyLast4 = last4(plain)
}
return nil
}
func patchAIExtras(dst *map[string]string, patch map[string]*string) error {
if patch == nil {
return nil
}
if *dst == nil {
*dst = map[string]string{}
}
for k, vp := range patch {
key := strings.TrimSpace(k)
if key == "" {
return ClientMsg("extras keys must be non-empty")
}
if strings.ContainsAny(key, " \t\n\r") {
return ClientMsg("extras keys must not contain whitespace")
}
if len(key) > maxAIExtrasKeyLen {
return ClientMsg(fmt.Sprintf("extras key exceeds %d characters", maxAIExtrasKeyLen))
}
if vp == nil {
delete(*dst, key)
continue
}
if len(*vp) > maxAIExtrasValLen {
return ClientMsg(fmt.Sprintf("extras value for %q exceeds %d characters", key, maxAIExtrasValLen))
}
(*dst)[key] = *vp
if len(*dst) > maxAIExtrasKeys {
return ClientMsg(fmt.Sprintf("extras may have at most %d keys", maxAIExtrasKeys))
}
}
if len(*dst) == 0 {
*dst = nil
}
return nil
}
func copyStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// syncProcessingFromOpenAI mirrors legacy openai into ai_roles.processing.
func syncProcessingFromOpenAI(doc *storedDoc) {
if doc.AIConfigs == nil {
doc.AIConfigs = map[string]aiConfigStored{}
}
st := doc.AIConfigs[AIRoleProcessing]
if strings.TrimSpace(st.Provider) == "" {
st.Provider = defaultAIProvider
}
st.BaseURL = doc.OpenAI.BaseURL
st.Model = doc.OpenAI.Model
st.APIKeyEnc = doc.OpenAI.APIKeyEnc
st.APIKeyLast4 = doc.OpenAI.APIKeyLast4
if strings.TrimSpace(st.APIKeyEnc) != "" {
st.Enabled = true
}
doc.AIConfigs[AIRoleProcessing] = st
}
// syncOpenAIFromProcessing mirrors processing role into legacy openai.
func syncOpenAIFromProcessing(doc *storedDoc) {
st, ok := doc.AIConfigs[AIRoleProcessing]
if !ok {
return
}
doc.OpenAI.BaseURL = st.BaseURL
doc.OpenAI.Model = st.Model
doc.OpenAI.APIKeyEnc = st.APIKeyEnc
doc.OpenAI.APIKeyLast4 = st.APIKeyLast4
}
// ResolveAIConfig returns plaintext credentials for a role (never log the key).
// processing falls back to legacy openai JSON then env when the role slot is empty.
//
// docs_api: config slot only until a future product hook; do not call from the
// guided /docs Ask decision tree (rule-based, no LLM).
func (s *Service) ResolveAIConfig(ctx context.Context, role string) (ResolvedAIConfig, error) {
role = strings.TrimSpace(role)
if !ValidAIRole(role) {
return ResolvedAIConfig{}, ClientMsg(fmt.Sprintf("unknown ai role %q", role))
}
if s == nil {
return ResolvedAIConfig{Role: role, Source: SourceNone}, nil
}
doc, _, err := s.loadDoc(ctx)
if err != nil {
return ResolvedAIConfig{}, err
}
st, ok := doc.AIConfigs[role]
hasRoleKey := ok && strings.TrimSpace(st.APIKeyEnc) != ""
hasRoleMeta := ok && (strings.TrimSpace(st.Provider) != "" ||
strings.TrimSpace(st.BaseURL) != "" ||
strings.TrimSpace(st.Model) != "" ||
st.Enabled ||
len(st.Extras) > 0)
if hasRoleKey {
plain, err := DecryptSecret(s.Key, st.APIKeyEnc)
if err != nil {
return ResolvedAIConfig{}, err
}
out := ResolvedAIConfig{
Role: role,
Provider: firstNonEmpty(strings.TrimSpace(st.Provider), defaultAIProvider),
APIKey: plain,
BaseURL: strings.TrimSpace(st.BaseURL),
Model: strings.TrimSpace(st.Model),
Enabled: st.Enabled,
Extras: copyStringMap(st.Extras),
Source: SourceDB,
}
if role == AIRoleProcessing {
if out.BaseURL == "" {
out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL)
}
if out.Model == "" {
out.Model = strings.TrimSpace(s.Env.OpenAIModel)
}
}
if role == AIRoleVectorization {
if out.BaseURL == "" {
out.BaseURL = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingBaseURL), strings.TrimSpace(s.Env.OpenAIBaseURL))
}
if out.Model == "" {
out.Model = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingModel), defaultEmbeddingModel)
}
}
return out, nil
}
if role == AIRoleProcessing && !hasRoleMeta {
legacy, err := s.resolveLegacyOpenAI(doc)
if err != nil {
return ResolvedAIConfig{}, err
}
return ResolvedAIConfig{
Role: role,
Provider: defaultAIProvider,
APIKey: legacy.APIKey,
BaseURL: legacy.BaseURL,
Model: legacy.Model,
Enabled: legacy.APIKey != "",
Source: legacy.Source,
}, nil
}
if role == AIRoleVectorization && !hasRoleMeta {
return s.resolveVectorizationEnv(), nil
}
out := ResolvedAIConfig{
Role: role,
Provider: strings.TrimSpace(st.Provider),
BaseURL: strings.TrimSpace(st.BaseURL),
Model: strings.TrimSpace(st.Model),
Enabled: st.Enabled,
Extras: copyStringMap(st.Extras),
Source: SourceNone,
}
if hasRoleMeta {
out.Source = SourceDB
}
if role == AIRoleVectorization && out.APIKey == "" {
env := s.resolveVectorizationEnv()
if env.APIKey != "" {
return env, nil
}
}
return out, nil
}
const defaultEmbeddingModel = "text-embedding-3-small"
// resolveVectorizationEnv uses OPENAI_EMBEDDING_* then shared OPENAI_* as bootstrap.
func (s *Service) resolveVectorizationEnv() ResolvedAIConfig {
out := ResolvedAIConfig{
Role: AIRoleVectorization,
Provider: defaultAIProvider,
Source: SourceNone,
}
if s == nil {
return out
}
key := firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingAPIKey), strings.TrimSpace(s.Env.OpenAIAPIKey))
if key == "" {
return out
}
out.APIKey = key
out.BaseURL = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingBaseURL), strings.TrimSpace(s.Env.OpenAIBaseURL))
out.Model = firstNonEmpty(strings.TrimSpace(s.Env.OpenAIEmbeddingModel), defaultEmbeddingModel)
out.Enabled = true
out.Source = SourceEnv
return out
}
func (s *Service) resolveLegacyOpenAI(doc storedDoc) (ResolvedOpenAI, error) {
out := ResolvedOpenAI{
BaseURL: strings.TrimSpace(doc.OpenAI.BaseURL),
Model: strings.TrimSpace(doc.OpenAI.Model),
Source: SourceNone,
}
if strings.TrimSpace(doc.OpenAI.APIKeyEnc) != "" {
plain, err := DecryptSecret(s.Key, doc.OpenAI.APIKeyEnc)
if err != nil {
return ResolvedOpenAI{}, err
}
out.APIKey = plain
out.Source = SourceDB
if out.BaseURL == "" {
out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL)
}
if out.Model == "" {
out.Model = strings.TrimSpace(s.Env.OpenAIModel)
}
return out, nil
}
if strings.TrimSpace(s.Env.OpenAIAPIKey) != "" {
out.APIKey = strings.TrimSpace(s.Env.OpenAIAPIKey)
out.Source = SourceEnv
if out.BaseURL == "" {
out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL)
}
if out.Model == "" {
out.Model = strings.TrimSpace(s.Env.OpenAIModel)
}
return out, nil
}
return out, nil
}
@@ -0,0 +1,39 @@
package platformsettings
import "testing"
func TestAIRolesIncludeDocsAPI(t *testing.T) {
t.Parallel()
if !ValidAIRole(AIRoleDocsAPI) {
t.Fatal("docs_api must be a valid admin AI config role")
}
found := false
for _, role := range AIRoles {
if role == AIRoleDocsAPI {
found = true
break
}
}
if !found {
t.Fatal("AIRoles catalog must include docs_api")
}
}
func TestPublicAIConfigsAlwaysExposesDocsAPISlot(t *testing.T) {
t.Parallel()
s := &Service{}
out := s.publicAIConfigs(storedDoc{})
slot, ok := out[AIRoleDocsAPI]
if !ok {
t.Fatal("GetPublic ai_roles must always include docs_api slot")
}
if slot.Role != AIRoleDocsAPI {
t.Fatalf("role = %q, want %q", slot.Role, AIRoleDocsAPI)
}
if slot.Configured {
t.Fatal("empty docs_api slot must not report configured")
}
if slot.Source != SourceNone {
t.Fatalf("source = %q, want %q", slot.Source, SourceNone)
}
}
@@ -0,0 +1,39 @@
package platformsettings
import "testing"
func TestAIRolesIncludeSupport(t *testing.T) {
t.Parallel()
if !ValidAIRole(AIRoleSupport) {
t.Fatal("support must be a valid admin AI config role")
}
found := false
for _, role := range AIRoles {
if role == AIRoleSupport {
found = true
break
}
}
if !found {
t.Fatal("AIRoles catalog must include support")
}
}
func TestPublicAIConfigsAlwaysExposesSupportSlot(t *testing.T) {
t.Parallel()
s := &Service{}
out := s.publicAIConfigs(storedDoc{})
slot, ok := out[AIRoleSupport]
if !ok {
t.Fatal("GetPublic ai_roles must always include support slot")
}
if slot.Role != AIRoleSupport {
t.Fatalf("role = %q, want %q", slot.Role, AIRoleSupport)
}
if slot.Configured {
t.Fatal("empty support slot must not report configured")
}
if slot.Source != SourceNone {
t.Fatalf("source = %q, want %q", slot.Source, SourceNone)
}
}
@@ -0,0 +1,163 @@
package platformsettings
import (
"context"
"strings"
"testing"
)
func TestValidAIRole(t *testing.T) {
for _, role := range AIRoles {
if !ValidAIRole(role) {
t.Fatalf("%q should be valid", role)
}
}
if ValidAIRole("embeddings") {
t.Fatal("embeddings alias is not a stored role key")
}
if ValidAIRole("") {
t.Fatal("empty role should be invalid")
}
}
func TestPublicAIConfigs_masksSecret(t *testing.T) {
key := DeriveKey("test-ai-config-secret-material", "fallback")
plain := "sk-live-super-secret-key"
enc, err := EncryptSecret(key, plain)
if err != nil {
t.Fatal(err)
}
svc := &Service{Key: key}
doc := storedDoc{
AIConfigs: map[string]aiConfigStored{
AIRoleSupport: {
Provider: "openai",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
APIKeyEnc: enc,
APIKeyLast4: last4(plain),
Enabled: true,
Extras: map[string]string{"temperature": "0.2"},
},
},
}
view := svc.publicAIConfigs(doc)
if len(view) != len(AIRoles) {
t.Fatalf("expected %d roles, got %d", len(AIRoles), len(view))
}
got := view[AIRoleSupport]
if got.APIKeyMasked == "" || strings.Contains(got.APIKeyMasked, "super-secret") {
t.Fatalf("api key not masked: %+v", got)
}
if got.HasAPIKey != true || got.APIKeyLast4 != last4(plain) {
t.Fatalf("unexpected mask meta: %+v", got)
}
if got.Extras["temperature"] != "0.2" {
t.Fatalf("extras: %+v", got.Extras)
}
if view[AIRoleDocsAPI].Role != AIRoleDocsAPI {
t.Fatalf("missing empty role stub: %+v", view[AIRoleDocsAPI])
}
}
func TestPublicAIConfigs_processingFallsBackToOpenAI(t *testing.T) {
key := DeriveKey("test-ai-config-secret-material", "fallback")
plain := "sk-legacy-abcdef12"
enc, err := EncryptSecret(key, plain)
if err != nil {
t.Fatal(err)
}
svc := &Service{Key: key}
doc := storedDoc{
OpenAI: openaiStored{
BaseURL: "https://example.test/v1",
Model: "gpt-test",
APIKeyEnc: enc,
APIKeyLast4: last4(plain),
},
}
view := svc.publicAIConfigs(doc)
got := view[AIRoleProcessing]
if !got.HasAPIKey || got.Source != SourceDB || got.Model != "gpt-test" {
t.Fatalf("processing fallback: %+v", got)
}
if strings.Contains(got.APIKeyMasked, "legacy") {
t.Fatalf("leaked key: %q", got.APIKeyMasked)
}
}
func TestResolveAIConfig_envFallback(t *testing.T) {
svc := NewService(nil, EnvConfig{
OpenAIAPIKey: "env-key-1234",
OpenAIBaseURL: "https://api.openai.com/v1",
OpenAIModel: "gpt-4o",
})
got, err := svc.ResolveAIConfig(context.Background(), AIRoleProcessing)
if err != nil {
t.Fatal(err)
}
if got.APIKey != "env-key-1234" || got.Source != SourceEnv || !got.Enabled {
t.Fatalf("got %+v", got)
}
support, err := svc.ResolveAIConfig(context.Background(), AIRoleSupport)
if err != nil {
t.Fatal(err)
}
if support.APIKey != "" || support.Source != SourceNone {
t.Fatalf("support should not use openai env: %+v", support)
}
vec, err := svc.ResolveAIConfig(context.Background(), AIRoleVectorization)
if err != nil {
t.Fatal(err)
}
if vec.APIKey != "env-key-1234" || vec.Source != SourceEnv || vec.Model == "" {
t.Fatalf("vectorization env fallback: %+v", vec)
}
}
func TestPatchAIExtras(t *testing.T) {
var extras map[string]string
val := "1536"
if err := patchAIExtras(&extras, map[string]*string{"dimensions": &val}); err != nil {
t.Fatal(err)
}
if extras["dimensions"] != "1536" {
t.Fatalf("got %#v", extras)
}
if err := patchAIExtras(&extras, map[string]*string{"dimensions": nil}); err != nil {
t.Fatal(err)
}
if extras != nil {
t.Fatalf("expected nil after delete, got %#v", extras)
}
if err := patchAIExtras(&extras, map[string]*string{"": &val}); err == nil {
t.Fatal("expected empty key error")
}
if err := patchAIExtras(&extras, map[string]*string{"bad key": &val}); err == nil {
t.Fatal("expected whitespace key error")
}
}
func TestPatchAIConfig_keepsSecretWhenOmitted(t *testing.T) {
key := DeriveKey("test-ai-config-secret-material", "fallback")
enc, err := EncryptSecret(key, "keep-me-secret")
if err != nil {
t.Fatal(err)
}
svc := &Service{Key: key}
st := aiConfigStored{APIKeyEnc: enc, APIKeyLast4: last4("keep-me-secret"), Provider: "openai"}
model := "new-model"
if err := svc.patchAIConfig(&st, AIConfigUpdate{Model: &model}); err != nil {
t.Fatal(err)
}
if st.APIKeyEnc != enc || st.Model != "new-model" {
t.Fatalf("unexpected state: %+v", st)
}
empty := ""
if err := svc.patchAIConfig(&st, AIConfigUpdate{APIKey: &empty}); err != nil {
t.Fatal(err)
}
if st.APIKeyEnc != enc {
t.Fatal("empty api_key should keep existing")
}
}
@@ -0,0 +1,12 @@
package platformsettings
import "strings"
func parseTruthy(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
@@ -0,0 +1,134 @@
package platformsettings
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"io"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
const encPrefix = "enc:v1:"
// DeriveKey builds a 32-byte AES key from APP_ENCRYPTION_KEY material.
// In production, empty explicit key returns nil (fail closed).
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
if b, err := decodeKeyMaterial(explicitKey); err == nil {
return b
}
sum := sha256.Sum256([]byte(explicitKey))
return sum[:]
}
if config.IsProductionEnv() {
return nil
}
sum := sha256.Sum256([]byte("descrybe-platform-settings-v1|" + fallbackMaterial))
return sum[:]
}
func decodeKeyMaterial(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
return b, nil
}
return nil, errors.New("invalid key material")
}
func EncryptSecret(key []byte, plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
}
func DecryptSecret(key []byte, stored string) (string, error) {
if stored == "" {
return "", nil
}
if !strings.HasPrefix(stored, encPrefix) {
if config.IsProductionEnv() {
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
}
return stored, nil
}
if len(key) != 32 {
return "", errors.New("encryption key must be 32 bytes")
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func maskSecret(plain string) string {
plain = strings.TrimSpace(plain)
if plain == "" {
return ""
}
if len(plain) <= 4 {
return "••••"
}
return "••••" + plain[len(plain)-4:]
}
func last4(s string) string {
s = strings.TrimSpace(s)
if len(s) <= 4 {
return s
}
return s[len(s)-4:]
}
func maskLast4(last4v string) string {
last4v = strings.TrimSpace(last4v)
if last4v == "" {
return ""
}
return "••••" + last4v
}
+21
View File
@@ -0,0 +1,21 @@
// Package platformsettings is the durable store for platform-level product and
// integration config (OpenAI, SMTP, OAuth, Stripe, EPREL, Pinecone, feed allowlist,
// generic KV) managed from the admin dashboard — so these values need not live
// only in process env.
//
// Storage (reuse, no new migration): company_settings JSONB for a reserved
// system company (SystemCompanyID). Secrets are AES-GCM enc:v1: blobs, same
// pattern as aiprovider/email/woocommerce. Integration tunables live in Values
// (see keys.go); secret Value keys are encrypted on write.
//
// Runtime reads (prefer DB, fall back to EnvConfig / process env):
//
// svc := platformsettings.NewService(pool, env)
// oi, err := svc.ResolveOpenAI(ctx)
// smtp, err := svc.ResolveSMTP(ctx)
// stripe, err := svc.ResolveStripe(ctx, base)
// eprel, err := svc.ResolveEPREL(ctx)
// pc, err := svc.ResolvePinecone(ctx)
// g, err := svc.ResolveOAuthGoogle(ctx)
// v, ok, err := svc.GetKV(ctx, "my.key")
package platformsettings
@@ -0,0 +1,32 @@
package platformsettings
import (
"context"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
)
// DynamicEPREL resolves platform settings on each call so admin changes
// apply without restarting the worker.
type DynamicEPREL struct {
Settings *Service
}
func (d *DynamicEPREL) Enabled() bool {
if d == nil || d.Settings == nil {
return false
}
opts, err := d.Settings.ResolveEPREL(context.Background())
return err == nil && opts.Enabled
}
func (d *DynamicEPREL) Fetch(ctx context.Context, eprelID string) (*eprel.Data, error) {
if d == nil || d.Settings == nil {
return nil, nil
}
client, err := d.Settings.NewEPRELClient(ctx)
if err != nil {
return nil, err
}
return client.Fetch(ctx, eprelID)
}
@@ -0,0 +1,27 @@
package platformsettings
import "errors"
// clientError is a validation message safe to return to API clients.
type clientError struct {
msg string
}
func (e *clientError) Error() string { return e.msg }
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
func ClientMsg(msg string) error {
return &clientError{msg: msg}
}
// ClientError reports whether err is a known client-facing settings error.
func ClientError(err error) (msg string, ok bool) {
if err == nil {
return "", false
}
var ce *clientError
if errors.As(err, &ce) {
return ce.msg, true
}
return "", false
}
+114
View File
@@ -0,0 +1,114 @@
package platformsettings
import (
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
// Well-known Values map keys for integrations (Agent 6).
// Stored in company_settings JSON under the system company (see Service).
const (
KeyStripeSecretKey = "stripe.secret_key"
KeyStripeWebhookSecret = "stripe.webhook_secret"
KeyStripeMock = "stripe.mock"
KeyStripePriceStarterMo = "stripe.price.starter.monthly"
KeyStripePriceStarterYr = "stripe.price.starter.yearly"
KeyStripePricePlusMo = "stripe.price.plus.monthly"
KeyStripePricePlusYr = "stripe.price.plus.yearly"
KeyStripePriceGrowthMo = "stripe.price.growth.monthly"
KeyStripePriceGrowthYr = "stripe.price.growth.yearly"
KeyStripePriceBizMo = "stripe.price.business.monthly"
KeyStripePriceBizYr = "stripe.price.business.yearly"
KeyStripePriceScaleMo = "stripe.price.scale.monthly"
KeyStripePriceScaleYr = "stripe.price.scale.yearly"
// Legacy aliases for common packs (also generated via billing.CreditPackSettingsKey).
KeyStripePricePackSmall = "stripe.price.pack.small"
KeyStripePricePackMedium = "stripe.price.pack.medium"
KeyStripePricePackLarge = "stripe.price.pack.large"
KeyStripePricePackXL = "stripe.price.pack.xl"
KeyEPRELEnabled = "eprel.enabled"
KeyEPRELBaseURL = "eprel.base_url"
KeyEPRELTimeout = "eprel.timeout"
KeyEPRELFicheLanguage = "eprel.fiche_language"
KeyEPRELAPIKey = "eprel.api_key"
KeyFeedPrivateAllowlist = "feeds.private_url_allowlist"
KeyPineconeAPIKey = "pinecone.api_key"
KeyPineconeHost = "pinecone.host"
KeyPineconeNamespace = "pinecone.namespace"
)
// SecretValueKeys are Values entries stored as enc:v1: ciphertext.
func SecretValueKeys() map[string]struct{} {
return map[string]struct{}{
KeyStripeSecretKey: {},
KeyStripeWebhookSecret: {},
KeyEPRELAPIKey: {},
ValueKeyResendAPIKey: {},
KeyPineconeAPIKey: {},
}
}
// AllowedValueKeys is the allowlist for Values bag writes (mass-assignment guard).
func AllowedValueKeys() map[string]struct{} {
out := map[string]struct{}{
KeyStripeSecretKey: {},
KeyStripeWebhookSecret: {},
KeyStripeMock: {},
KeyStripePriceStarterMo: {},
KeyStripePriceStarterYr: {},
KeyStripePricePlusMo: {},
KeyStripePricePlusYr: {},
KeyStripePriceGrowthMo: {},
KeyStripePriceGrowthYr: {},
KeyStripePriceBizMo: {},
KeyStripePriceBizYr: {},
KeyStripePriceScaleMo: {},
KeyStripePriceScaleYr: {},
KeyEPRELEnabled: {},
KeyEPRELBaseURL: {},
KeyEPRELTimeout: {},
KeyEPRELFicheLanguage: {},
KeyEPRELAPIKey: {},
KeyFeedPrivateAllowlist: {},
KeyPineconeAPIKey: {},
KeyPineconeHost: {},
KeyPineconeNamespace: {},
ValueKeyResendAPIKey: {},
ValueKeyEmailDryRun: {},
}
for _, pack := range billing.DefaultCreditPacks() {
out[billing.CreditPackSettingsKey(pack.ID)] = struct{}{}
}
return out
}
func isSecretValueKey(key string) bool {
_, ok := SecretValueKeys()[key]
return ok
}
func isAllowedValueKey(key string) bool {
_, ok := AllowedValueKeys()[key]
return ok
}
// EPREL fiche language codes accepted by the admin UI / EC API language query param.
var eprelFicheLanguages = map[string]struct{}{
"EN": {}, "DE": {}, "FR": {}, "NL": {}, "ES": {}, "IT": {},
}
// NormalizeEPRELFicheLanguage uppercases and allowlists fiche language codes.
func NormalizeEPRELFicheLanguage(raw string) (string, error) {
code := strings.ToUpper(strings.TrimSpace(raw))
if code == "" {
return "EN", nil
}
if _, ok := eprelFicheLanguages[code]; !ok {
return "", ClientMsg("unsupported eprel fiche language")
}
return code, nil
}
@@ -0,0 +1,95 @@
package platformsettings
import (
"context"
"strings"
)
// mailPublicFromSMTP maps SMTPPublic into the flat admin UI shape.
func mailPublicFromSMTP(smtp SMTPPublic) MailPublic {
return MailPublic{
Configured: smtp.Configured || smtp.HasResendAPIKey,
SMTPEnabled: smtp.Enabled,
SMTPHost: smtp.Host,
SMTPPort: smtp.Port,
SMTPUser: smtp.User,
SMTPFrom: smtp.From,
HasSMTPPassword: smtp.HasPassword,
SMTPPasswordMasked: smtp.PasswordMasked,
HasResendAPIKey: smtp.HasResendAPIKey,
ResendAPIKeyMasked: smtp.ResendAPIKeyMasked,
EmailDryRun: smtp.EmailDryRun,
Source: smtp.Source,
}
}
// mailUpdateToSMTP converts the flat admin UI patch into SMTPUpdate.
func mailUpdateToSMTP(in MailUpdate) SMTPUpdate {
return SMTPUpdate{
Enabled: in.SMTPEnabled,
Host: in.SMTPHost,
Port: in.SMTPPort,
User: in.SMTPUser,
From: in.SMTPFrom,
Password: in.SMTPPassword,
ClearPassword: in.ClearSMTPPassword,
ResendAPIKey: in.ResendAPIKey,
ClearResendAPIKey: in.ClearResendAPIKey,
EmailDryRun: in.EmailDryRun,
}
}
// ResolveResend returns the platform Resend API key (DB preferred, then env).
func (s *Service) ResolveResend(ctx context.Context) (ResolvedResend, error) {
doc, _, err := s.loadDoc(ctx)
if err != nil {
return ResolvedResend{}, err
}
st := doc.SMTP
if strings.TrimSpace(st.ResendAPIKeyEnc) != "" {
plain, err := DecryptSecret(s.Key, st.ResendAPIKeyEnc)
if err != nil {
return ResolvedResend{}, err
}
return ResolvedResend{APIKey: plain, Source: SourceDB}, nil
}
// Legacy Values bag (Agent 3/6 may have written mail.resend_api_key).
if v, ok := doc.Values[ValueKeyResendAPIKey]; ok && strings.TrimSpace(v) != "" {
plain := v
if strings.HasPrefix(v, encPrefix) || isSecretValueKey(ValueKeyResendAPIKey) {
dec, err := DecryptSecret(s.Key, v)
if err != nil {
return ResolvedResend{}, err
}
plain = dec
}
if strings.TrimSpace(plain) != "" {
return ResolvedResend{APIKey: plain, Source: SourceDB}, nil
}
}
if strings.TrimSpace(s.Env.ResendAPIKey) != "" {
return ResolvedResend{APIKey: strings.TrimSpace(s.Env.ResendAPIKey), Source: SourceEnv}, nil
}
return ResolvedResend{Source: SourceNone}, nil
}
// ResolveEmailDryRun returns whether platform email should force dry-run.
// Default is true (safe) when neither settings nor env set the flag.
func (s *Service) ResolveEmailDryRun(ctx context.Context) (ResolvedEmailDryRun, error) {
doc, _, err := s.loadDoc(ctx)
if err != nil {
return ResolvedEmailDryRun{}, err
}
st := doc.SMTP
if st.EmailDryRun != nil {
return ResolvedEmailDryRun{DryRun: *st.EmailDryRun, Source: SourceDB}, nil
}
if v, ok := doc.Values[ValueKeyEmailDryRun]; ok && strings.TrimSpace(v) != "" {
return ResolvedEmailDryRun{DryRun: parseTruthy(v), Source: SourceDB}, nil
}
if s.Env.EmailDryRunSet {
return ResolvedEmailDryRun{DryRun: s.Env.EmailDryRun, Source: SourceEnv}, nil
}
// Safe default: dry-run on until admin configures live delivery.
return ResolvedEmailDryRun{DryRun: true, Source: SourceNone}, nil
}
@@ -0,0 +1,64 @@
package platformsettings
import (
"context"
"errors"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
)
// DynamicPinecone resolves platform settings on each call so admin changes
// apply without restarting the worker. Embeddings use admin AI role
// "vectorization" (ResolveAIConfig) with OPENAI_EMBEDDING_* / OPENAI_* env fallback.
type DynamicPinecone struct {
Settings *Service
}
func (d *DynamicPinecone) Enabled() bool {
if d == nil || d.Settings == nil {
return false
}
cfg, err := d.Settings.ResolvePinecone(context.Background())
return err == nil && cfg.Configured()
}
func (d *DynamicPinecone) SuggestCategory(ctx context.Context, companyID, productText string, candidates []string) (string, error) {
if d == nil || d.Settings == nil {
return "", errors.New("pinecone not configured")
}
cfg, err := d.Settings.ResolvePinecone(ctx)
if err != nil {
return "", err
}
if !cfg.Configured() {
return "", errors.New("pinecone not configured")
}
cat := processing.NewPineconeCategorizer(cfg.APIKey, cfg.Host, cfg.Namespace)
if emb, eerr := d.Settings.ResolveEmbedder(ctx); eerr == nil && emb != nil {
cat.Embedder = emb
}
return cat.SuggestCategory(ctx, companyID, productText, candidates)
}
// ResolveEmbedder builds an OpenAI-compatible Embedder from admin AI role
// "vectorization" (DB), falling back to OPENAI_EMBEDDING_* then OPENAI_* env.
// Returns (nil, nil) when unset so callers can keep Pinecone text-query mode.
func (s *Service) ResolveEmbedder(ctx context.Context) (processing.Embedder, error) {
if s == nil {
return nil, nil
}
cfg, err := s.ResolveAIConfig(ctx, AIRoleVectorization)
if err != nil {
return nil, err
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, nil
}
model := strings.TrimSpace(cfg.Model)
if model == "" {
model = defaultEmbeddingModel
}
client := processing.NewOpenAIClient(cfg.APIKey, cfg.BaseURL, model, 0, 3)
return client, nil
}
@@ -0,0 +1,216 @@
package platformsettings
import (
"context"
"os"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
)
// ResolveStripe merges platform Values over env/base StripeConfig.
// Non-empty DB values win; price IDs use "starter:monthly" keys.
func (s *Service) ResolveStripe(ctx context.Context, base billing.StripeConfig) (billing.StripeConfig, error) {
out := base
if out.PriceIDs == nil {
out.PriceIDs = map[string]string{}
} else {
cp := make(map[string]string, len(out.PriceIDs))
for k, v := range out.PriceIDs {
cp[k] = v
}
out.PriceIDs = cp
}
if v, ok, err := s.GetKV(ctx, KeyStripeSecretKey); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.SecretKey = v
}
if v, ok, err := s.GetKV(ctx, KeyStripeWebhookSecret); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.WebhookSecret = v
}
if v, ok, err := s.GetKV(ctx, KeyStripeMock); err != nil {
return out, err
} else if ok {
out.ForceMock = parseTruthy(v)
}
priceKeys := []struct {
setting string
price string
}{
{KeyStripePriceStarterMo, "starter:monthly"},
{KeyStripePriceStarterYr, "starter:yearly"},
{KeyStripePricePlusMo, "plus:monthly"},
{KeyStripePricePlusYr, "plus:yearly"},
{KeyStripePriceGrowthMo, "growth:monthly"},
{KeyStripePriceGrowthYr, "growth:yearly"},
{KeyStripePriceBizMo, "business:monthly"},
{KeyStripePriceBizYr, "business:yearly"},
{KeyStripePriceScaleMo, "scale:monthly"},
{KeyStripePriceScaleYr, "scale:yearly"},
}
for _, p := range priceKeys {
if v, ok, err := s.GetKV(ctx, p.setting); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.PriceIDs[p.price] = strings.TrimSpace(v)
}
}
for _, pack := range billing.DefaultCreditPacks() {
setting := billing.CreditPackSettingsKey(pack.ID)
if v, ok, err := s.GetKV(ctx, setting); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.PriceIDs[billing.CreditPackPriceKey(pack.ID)] = strings.TrimSpace(v)
}
}
return out, nil
}
// EPRELOptions is the runtime EPREL client config after settings merge.
type EPRELOptions struct {
Enabled bool
BaseURL string
Timeout time.Duration
FicheLanguage string
APIKey string
}
// ResolveEPREL merges Values over EnvConfig EPREL fields (when set) and defaults.
// Enrichment defaults on (EPREL_ENABLED env default true); admin eprel.enabled overrides when set.
// API key is optional — the public EU EPREL API does not require authentication.
func (s *Service) ResolveEPREL(ctx context.Context) (EPRELOptions, error) {
out := EPRELOptions{
Enabled: s.Env.EPRELEnabled,
BaseURL: s.Env.EPRELBaseURL,
Timeout: s.Env.EPRELTimeout,
FicheLanguage: s.Env.EPRELFicheLanguage,
APIKey: s.Env.EPRELAPIKey,
}
if out.BaseURL == "" {
out.BaseURL = "https://eprel.ec.europa.eu/api"
}
if out.Timeout <= 0 {
out.Timeout = 10 * time.Second
}
if out.FicheLanguage == "" {
out.FicheLanguage = "EN"
}
if v, ok, err := s.GetKV(ctx, KeyEPRELEnabled); err != nil {
return out, err
} else if ok {
out.Enabled = parseTruthy(v)
}
if v, ok, err := s.GetKV(ctx, KeyEPRELBaseURL); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.BaseURL = strings.TrimSpace(v)
}
if v, ok, err := s.GetKV(ctx, KeyEPRELTimeout); err != nil {
return out, err
} else if ok {
if d, okDur := parseDuration(v); okDur {
out.Timeout = d
}
}
if v, ok, err := s.GetKV(ctx, KeyEPRELFicheLanguage); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
if code, nerr := NormalizeEPRELFicheLanguage(v); nerr == nil {
out.FicheLanguage = code
}
}
if v, ok, err := s.GetKV(ctx, KeyEPRELAPIKey); err != nil {
return out, err
} else if ok {
out.APIKey = strings.TrimSpace(v)
}
return out, nil
}
// NewEPRELClient builds an EPREL client from ResolveEPREL.
func (s *Service) NewEPRELClient(ctx context.Context) (*eprel.Client, error) {
opts, err := s.ResolveEPREL(ctx)
if err != nil {
return nil, err
}
return eprel.NewClient(eprel.Options{
Enabled: opts.Enabled,
BaseURL: opts.BaseURL,
Timeout: opts.Timeout,
FicheLanguage: opts.FicheLanguage,
APIKey: opts.APIKey,
}), nil
}
// ResolvedPinecone is runtime Pinecone config after settings merge (plaintext key — never log).
type ResolvedPinecone struct {
APIKey string
Host string
Namespace string
}
// Configured reports whether vector features can run (key + host required).
func (r ResolvedPinecone) Configured() bool {
return strings.TrimSpace(r.APIKey) != "" && strings.TrimSpace(r.Host) != ""
}
// ResolvePinecone merges Values over EnvConfig Pinecone fields (DB wins when set).
func (s *Service) ResolvePinecone(ctx context.Context) (ResolvedPinecone, error) {
out := ResolvedPinecone{
APIKey: strings.TrimSpace(s.Env.PineconeAPIKey),
Host: strings.TrimSpace(s.Env.PineconeHost),
Namespace: strings.TrimSpace(s.Env.PineconeNamespace),
}
if v, ok, err := s.GetKV(ctx, KeyPineconeAPIKey); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.APIKey = strings.TrimSpace(v)
}
if v, ok, err := s.GetKV(ctx, KeyPineconeHost); err != nil {
return out, err
} else if ok && strings.TrimSpace(v) != "" {
out.Host = strings.TrimSpace(v)
}
if v, ok, err := s.GetKV(ctx, KeyPineconeNamespace); err != nil {
return out, err
} else if ok {
out.Namespace = strings.TrimSpace(v)
}
return out, nil
}
// ResolveFeedPrivateAllowlist returns settings CSV, falling back to env.
func (s *Service) ResolveFeedPrivateAllowlist(ctx context.Context) (string, error) {
if v, ok, err := s.GetKV(ctx, KeyFeedPrivateAllowlist); err != nil {
return "", err
} else if ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v), nil
}
if strings.TrimSpace(s.Env.FeedPrivateAllowlist) != "" {
return strings.TrimSpace(s.Env.FeedPrivateAllowlist), nil
}
return strings.TrimSpace(os.Getenv("FEED_URL_PRIVATE_ALLOWLIST")), nil
}
func parseDuration(v string) (time.Duration, bool) {
v = strings.TrimSpace(v)
if v == "" {
return 0, false
}
if d, err := time.ParseDuration(v); err == nil {
return d, true
}
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return time.Duration(n) * time.Second, true
}
return 0, false
}
@@ -0,0 +1,672 @@
package platformsettings
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// SystemCompanyID is the reserved companies.id used to hold platform settings
// inside company_settings (reuse existing table — no migration).
// Fixed UUID v4-shaped value; never expose as a selectable tenant.
var SystemCompanyID = uuid.MustParse("00000000-0000-4000-8000-000000000001")
// SystemCompanyName is stored on the sentinel companies row; filtered from admin lists.
const SystemCompanyName = "__platform_settings__"
// Service loads and updates platform integration settings.
type Service struct {
Pool *pgxpool.Pool
Key []byte
Env EnvConfig
}
// NewService builds a Service. Encryption key follows APP_ENCRYPTION_KEY chain.
func NewService(pool *pgxpool.Pool, env EnvConfig) *Service {
keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret)
return &Service{
Pool: pool,
Key: DeriveKey(keyMaterial, env.DatabaseURL),
Env: env,
}
}
// IsSystemCompany reports whether id is the platform-settings sentinel.
func IsSystemCompany(id uuid.UUID) bool {
return id == SystemCompanyID
}
type storedDoc struct {
OpenAI openaiStored `json:"openai"`
AIConfigs map[string]aiConfigStored `json:"ai_roles,omitempty"`
SMTP smtpStored `json:"smtp"`
OAuth oauthStored `json:"oauth"`
Values map[string]string `json:"values"`
}
type openaiStored struct {
BaseURL string `json:"base_url"`
Model string `json:"model"`
APIKeyEnc string `json:"api_key_enc"`
APIKeyLast4 string `json:"api_key_last4"`
}
type smtpStored struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port string `json:"port"`
User string `json:"user"`
From string `json:"from"`
PasswordEnc string `json:"password_enc"`
PasswordLast4 string `json:"password_last4"`
ResendAPIKeyEnc string `json:"resend_api_key_enc,omitempty"`
ResendAPIKeyLast4 string `json:"resend_api_key_last4,omitempty"`
EmailDryRun *bool `json:"email_dry_run,omitempty"`
}
type oauthStored struct {
Google googleOAuthStored `json:"google"`
}
type googleOAuthStored struct {
Enabled bool `json:"enabled"`
ClientID string `json:"client_id"`
ClientSecretEnc string `json:"client_secret_enc"`
ClientSecretLast4 string `json:"client_secret_last4"`
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func (s *Service) ensureSystemCompany(ctx context.Context) error {
_, err := s.Pool.Exec(ctx, `
INSERT INTO companies (id, name, language)
VALUES ($1, $2, 'en')
ON CONFLICT (id) DO NOTHING`, SystemCompanyID, SystemCompanyName)
return err
}
func (s *Service) loadDoc(ctx context.Context) (storedDoc, time.Time, error) {
if s == nil || s.Pool == nil {
return storedDoc{Values: map[string]string{}}, time.Time{}, nil
}
var raw []byte
var updated time.Time
err := s.Pool.QueryRow(ctx, `
SELECT settings, updated_at FROM company_settings WHERE company_id = $1`, SystemCompanyID).Scan(&raw, &updated)
if errors.Is(err, pgx.ErrNoRows) {
return storedDoc{Values: map[string]string{}}, time.Time{}, nil
}
if err != nil {
return storedDoc{}, time.Time{}, err
}
doc := storedDoc{Values: map[string]string{}}
if len(raw) > 0 && string(raw) != "null" {
if err := json.Unmarshal(raw, &doc); err != nil {
return storedDoc{}, time.Time{}, err
}
}
if doc.Values == nil {
doc.Values = map[string]string{}
}
return doc, updated, nil
}
func (s *Service) saveDoc(ctx context.Context, doc storedDoc) error {
if s == nil || s.Pool == nil {
return ClientMsg("database unavailable")
}
if err := s.ensureSystemCompany(ctx); err != nil {
return err
}
if doc.Values == nil {
doc.Values = map[string]string{}
}
raw, err := json.Marshal(doc)
if err != nil {
return err
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (company_id) DO UPDATE SET
settings = EXCLUDED.settings,
updated_at = now()`, SystemCompanyID, raw)
return err
}
// GetPublic returns the admin-safe view (masked secrets + env fallback flags).
func (s *Service) GetPublic(ctx context.Context) (PublicView, error) {
doc, updated, err := s.loadDoc(ctx)
if err != nil {
return PublicView{}, err
}
view := PublicView{
Values: map[string]string{},
OAuth: OAuthPublic{},
}
for k, v := range doc.Values {
if isSecretValueKey(k) {
if strings.TrimSpace(v) == "" {
view.Values[k] = ""
continue
}
plain, decErr := DecryptSecret(s.Key, v)
if decErr != nil {
view.Values[k] = "••••"
continue
}
view.Values[k] = maskSecret(plain)
continue
}
view.Values[k] = v
}
if !updated.IsZero() {
u := updated.UTC().Format(time.RFC3339)
view.Updated = &u
}
view.OpenAI = s.publicOpenAI(doc.OpenAI)
view.AIConfigs = s.publicAIConfigs(doc)
view.SMTP = s.publicSMTP(doc.SMTP)
view.Mail = mailPublicFromSMTP(view.SMTP)
view.OAuth.Google = s.publicGoogle(doc.OAuth.Google)
return view, nil
}
func (s *Service) publicOpenAI(st openaiStored) OpenAIPublic {
hasDB := strings.TrimSpace(st.APIKeyEnc) != ""
envKey := strings.TrimSpace(s.Env.OpenAIAPIKey) != ""
out := OpenAIPublic{
BaseURL: strings.TrimSpace(st.BaseURL),
Model: strings.TrimSpace(st.Model),
Source: SourceNone,
}
if hasDB {
out.Configured = true
out.HasAPIKey = true
out.APIKeyLast4 = st.APIKeyLast4
out.APIKeyMasked = maskLast4(st.APIKeyLast4)
out.Source = SourceDB
return out
}
if envKey {
out.Configured = true
out.HasAPIKey = true
out.Source = SourceEnv
if out.BaseURL == "" {
out.BaseURL = strings.TrimSpace(s.Env.OpenAIBaseURL)
}
if out.Model == "" {
out.Model = strings.TrimSpace(s.Env.OpenAIModel)
}
return out
}
if out.BaseURL != "" || out.Model != "" {
out.Configured = true
out.Source = SourceDB
}
return out
}
func (s *Service) publicSMTP(st smtpStored) SMTPPublic {
hasDBPass := strings.TrimSpace(st.PasswordEnc) != ""
hasDBHost := strings.TrimSpace(st.Host) != ""
hasDBResend := strings.TrimSpace(st.ResendAPIKeyEnc) != ""
envHost := strings.TrimSpace(s.Env.SMTPHost) != ""
envResend := strings.TrimSpace(s.Env.ResendAPIKey) != ""
dryRun, drySrc := s.emailDryRunFromStored(st)
out := SMTPPublic{
Enabled: st.Enabled,
Host: strings.TrimSpace(st.Host),
Port: strings.TrimSpace(st.Port),
User: strings.TrimSpace(st.User),
From: strings.TrimSpace(st.From),
EmailDryRun: dryRun,
Source: SourceNone,
}
if hasDBHost || hasDBPass || hasDBResend || st.Enabled || st.EmailDryRun != nil {
out.Configured = hasDBHost || hasDBPass || hasDBResend
out.HasPassword = hasDBPass
out.PasswordLast4 = st.PasswordLast4
out.PasswordMasked = maskLast4(st.PasswordLast4)
out.HasResendAPIKey = hasDBResend
out.ResendAPIKeyLast4 = st.ResendAPIKeyLast4
out.ResendAPIKeyMasked = maskLast4(st.ResendAPIKeyLast4)
out.Source = SourceDB
if drySrc == SourceEnv && st.EmailDryRun == nil {
// keep EmailDryRun from env when DB did not set it
}
return out
}
if envHost || s.Env.SMTPEnabled || envResend {
out.Configured = true
out.Enabled = s.Env.SMTPEnabled
out.Host = strings.TrimSpace(s.Env.SMTPHost)
out.Port = strings.TrimSpace(s.Env.SMTPPort)
out.User = strings.TrimSpace(s.Env.SMTPUser)
out.From = strings.TrimSpace(s.Env.SMTPFrom)
out.HasPassword = strings.TrimSpace(s.Env.SMTPPassword) != ""
out.HasResendAPIKey = envResend
out.Source = SourceEnv
return out
}
return out
}
func (s *Service) emailDryRunFromStored(st smtpStored) (dry bool, src string) {
if st.EmailDryRun != nil {
return *st.EmailDryRun, SourceDB
}
if s.Env.EmailDryRunSet {
return s.Env.EmailDryRun, SourceEnv
}
return true, SourceNone
}
func (s *Service) publicGoogle(st googleOAuthStored) GoogleOAuthPublic {
hasDB := strings.TrimSpace(st.ClientSecretEnc) != "" || strings.TrimSpace(st.ClientID) != ""
envOK := strings.TrimSpace(s.Env.GoogleClientID) != "" || strings.TrimSpace(s.Env.GoogleClientSecret) != ""
out := GoogleOAuthPublic{
Enabled: st.Enabled,
ClientID: strings.TrimSpace(st.ClientID),
Source: SourceNone,
}
if hasDB {
out.Configured = true
out.HasClientSecret = strings.TrimSpace(st.ClientSecretEnc) != ""
out.ClientSecretLast4 = st.ClientSecretLast4
out.ClientSecretMasked = maskLast4(st.ClientSecretLast4)
out.Source = SourceDB
return out
}
if envOK {
out.Configured = true
out.ClientID = strings.TrimSpace(s.Env.GoogleClientID)
out.HasClientSecret = strings.TrimSpace(s.Env.GoogleClientSecret) != ""
out.Source = SourceEnv
return out
}
return out
}
// Update applies a partial patch and returns the refreshed public view.
func (s *Service) Update(ctx context.Context, in UpdateInput) (PublicView, error) {
doc, _, err := s.loadDoc(ctx)
if err != nil {
return PublicView{}, err
}
if doc.Values == nil {
doc.Values = map[string]string{}
}
if in.OpenAI != nil {
if err := s.patchOpenAI(&doc.OpenAI, *in.OpenAI); err != nil {
return PublicView{}, err
}
syncProcessingFromOpenAI(&doc)
}
if in.AIConfigs != nil {
if err := s.patchAIConfigs(&doc, in.AIConfigs); err != nil {
return PublicView{}, err
}
if _, ok := in.AIConfigs[AIRoleProcessing]; ok {
syncOpenAIFromProcessing(&doc)
}
}
if in.SMTP != nil {
if err := s.patchSMTP(&doc.SMTP, *in.SMTP); err != nil {
return PublicView{}, err
}
}
if in.Mail != nil {
if err := s.patchSMTP(&doc.SMTP, mailUpdateToSMTP(*in.Mail)); err != nil {
return PublicView{}, err
}
}
if in.OAuth != nil && in.OAuth.Google != nil {
if err := s.patchGoogle(&doc.OAuth.Google, *in.OAuth.Google); err != nil {
return PublicView{}, err
}
}
if in.Values != nil {
for k, vp := range in.Values {
key := strings.TrimSpace(k)
if key == "" {
return PublicView{}, ClientMsg("values keys must be non-empty")
}
if strings.ContainsAny(key, " \t\n\r") {
return PublicView{}, ClientMsg("values keys must not contain whitespace")
}
if !isAllowedValueKey(key) {
return PublicView{}, ClientMsg("unknown settings key")
}
if vp == nil {
delete(doc.Values, key)
continue
}
val := *vp
if key == KeyEPRELFicheLanguage {
normalized, nerr := NormalizeEPRELFicheLanguage(val)
if nerr != nil {
return PublicView{}, nerr
}
val = normalized
}
if key == KeyEPRELBaseURL {
u := strings.TrimSpace(val)
if u != "" {
normalized, uerr := security.ValidatePublicHTTPSURL(u)
if uerr != nil || normalized == "" {
return PublicView{}, ClientMsg("invalid eprel base_url")
}
val = strings.TrimRight(normalized, "/")
}
}
if isSecretValueKey(key) {
plain := strings.TrimSpace(val)
if plain == "" {
// Keep existing secret when admin submits blank (masked UI).
continue
}
enc, encErr := EncryptSecret(s.Key, plain)
if encErr != nil {
return PublicView{}, encErr
}
doc.Values[key] = enc
continue
}
doc.Values[key] = val
}
}
if err := s.saveDoc(ctx, doc); err != nil {
return PublicView{}, err
}
return s.GetPublic(ctx)
}
func (s *Service) patchOpenAI(st *openaiStored, in OpenAIUpdate) error {
if in.BaseURL != nil {
u := strings.TrimSpace(*in.BaseURL)
if u != "" {
normalized, err := security.ValidatePublicHTTPSURL(u)
if err != nil || normalized == "" {
return ClientMsg("invalid openai base_url")
}
u = strings.TrimRight(normalized, "/")
}
st.BaseURL = u
}
if in.Model != nil {
st.Model = strings.TrimSpace(*in.Model)
}
if in.ClearAPIKey {
st.APIKeyEnc = ""
st.APIKeyLast4 = ""
return nil
}
if in.APIKey != nil {
plain := strings.TrimSpace(*in.APIKey)
if plain == "" {
return nil // empty string = keep existing (same as omit for convenience)
}
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return err
}
st.APIKeyEnc = enc
st.APIKeyLast4 = last4(plain)
}
return nil
}
func (s *Service) patchSMTP(st *smtpStored, in SMTPUpdate) error {
if in.Enabled != nil {
st.Enabled = *in.Enabled
}
if in.Host != nil {
st.Host = strings.TrimSpace(*in.Host)
}
if in.Port != nil {
st.Port = strings.TrimSpace(*in.Port)
}
if in.User != nil {
st.User = strings.TrimSpace(*in.User)
}
if in.From != nil {
st.From = strings.TrimSpace(*in.From)
}
if in.EmailDryRun != nil {
v := *in.EmailDryRun
st.EmailDryRun = &v
}
if in.ClearPassword {
st.PasswordEnc = ""
st.PasswordLast4 = ""
} else if in.Password != nil {
plain := strings.TrimSpace(*in.Password)
if plain != "" {
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return err
}
st.PasswordEnc = enc
st.PasswordLast4 = last4(plain)
}
}
if in.ClearResendAPIKey {
st.ResendAPIKeyEnc = ""
st.ResendAPIKeyLast4 = ""
} else if in.ResendAPIKey != nil {
plain := strings.TrimSpace(*in.ResendAPIKey)
if plain != "" {
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return err
}
st.ResendAPIKeyEnc = enc
st.ResendAPIKeyLast4 = last4(plain)
}
}
return nil
}
func (s *Service) patchGoogle(st *googleOAuthStored, in GoogleOAuthUpdate) error {
if in.Enabled != nil {
st.Enabled = *in.Enabled
}
if in.ClientID != nil {
st.ClientID = strings.TrimSpace(*in.ClientID)
}
if in.ClearClientSecret {
st.ClientSecretEnc = ""
st.ClientSecretLast4 = ""
return nil
}
if in.ClientSecret != nil {
plain := strings.TrimSpace(*in.ClientSecret)
if plain == "" {
return nil
}
enc, err := EncryptSecret(s.Key, plain)
if err != nil {
return err
}
st.ClientSecretEnc = enc
st.ClientSecretLast4 = last4(plain)
}
return nil
}
// ResolveOpenAI returns plaintext OpenAI credentials (DB preferred, then env).
// Prefer ResolveAIConfig(AIRoleProcessing) for role-aware callers.
func (s *Service) ResolveOpenAI(ctx context.Context) (ResolvedOpenAI, error) {
cfg, err := s.ResolveAIConfig(ctx, AIRoleProcessing)
if err != nil {
return ResolvedOpenAI{}, err
}
return ResolvedOpenAI{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Source: cfg.Source,
}, nil
}
// ResolveSMTP returns plaintext SMTP settings (DB preferred, then env).
func (s *Service) ResolveSMTP(ctx context.Context) (ResolvedSMTP, error) {
doc, _, err := s.loadDoc(ctx)
if err != nil {
return ResolvedSMTP{}, err
}
st := doc.SMTP
hasDB := strings.TrimSpace(st.Host) != "" || strings.TrimSpace(st.PasswordEnc) != "" || st.Enabled
if hasDB {
out := ResolvedSMTP{
Enabled: st.Enabled,
Host: strings.TrimSpace(st.Host),
Port: strings.TrimSpace(st.Port),
User: strings.TrimSpace(st.User),
From: strings.TrimSpace(st.From),
Source: SourceDB,
}
if out.Port == "" {
out.Port = "587"
}
if strings.TrimSpace(st.PasswordEnc) != "" {
plain, err := DecryptSecret(s.Key, st.PasswordEnc)
if err != nil {
return ResolvedSMTP{}, err
}
out.Password = plain
}
return out, nil
}
return ResolvedSMTP{
Enabled: s.Env.SMTPEnabled,
Host: strings.TrimSpace(s.Env.SMTPHost),
Port: firstNonEmpty(strings.TrimSpace(s.Env.SMTPPort), "587"),
User: strings.TrimSpace(s.Env.SMTPUser),
Password: s.Env.SMTPPassword,
From: strings.TrimSpace(s.Env.SMTPFrom),
Source: func() string {
if s.Env.SMTPEnabled || strings.TrimSpace(s.Env.SMTPHost) != "" {
return SourceEnv
}
return SourceNone
}(),
}, nil
}
// ResolveOAuthGoogle returns plaintext Google OAuth credentials.
func (s *Service) ResolveOAuthGoogle(ctx context.Context) (ResolvedOAuthGoogle, error) {
doc, _, err := s.loadDoc(ctx)
if err != nil {
return ResolvedOAuthGoogle{}, err
}
st := doc.OAuth.Google
hasDB := strings.TrimSpace(st.ClientID) != "" || strings.TrimSpace(st.ClientSecretEnc) != ""
if hasDB {
out := ResolvedOAuthGoogle{
Enabled: st.Enabled,
ClientID: strings.TrimSpace(st.ClientID),
Source: SourceDB,
}
if strings.TrimSpace(st.ClientSecretEnc) != "" {
plain, err := DecryptSecret(s.Key, st.ClientSecretEnc)
if err != nil {
return ResolvedOAuthGoogle{}, err
}
out.ClientSecret = plain
}
return out, nil
}
return ResolvedOAuthGoogle{
Enabled: strings.TrimSpace(s.Env.GoogleClientID) != "" && strings.TrimSpace(s.Env.GoogleClientSecret) != "",
ClientID: strings.TrimSpace(s.Env.GoogleClientID),
ClientSecret: s.Env.GoogleClientSecret,
Source: SourceEnv,
}, nil
}
// GetKV reads a non-secret platform value. ok is false when unset.
func (s *Service) GetKV(ctx context.Context, key string) (value string, ok bool, err error) {
key = strings.TrimSpace(key)
if key == "" {
return "", false, ClientMsg("key required")
}
doc, _, err := s.loadDoc(ctx)
if err != nil {
return "", false, err
}
v, ok := doc.Values[key]
if !ok {
return "", false, nil
}
if isSecretValueKey(key) || strings.HasPrefix(v, encPrefix) {
plain, err := DecryptSecret(s.Key, v)
if err != nil {
return "", false, err
}
return plain, true, nil
}
return v, true, nil
}
// SetKV writes a non-secret platform value (empty value stores "").
func (s *Service) SetKV(ctx context.Context, key, value string) error {
key = strings.TrimSpace(key)
if key == "" {
return ClientMsg("key required")
}
if !isAllowedValueKey(key) {
return ClientMsg("unknown settings key")
}
if key == KeyEPRELFicheLanguage {
normalized, err := NormalizeEPRELFicheLanguage(value)
if err != nil {
return err
}
value = normalized
}
if key == KeyEPRELBaseURL {
u := strings.TrimSpace(value)
if u != "" {
normalized, err := security.ValidatePublicHTTPSURL(u)
if err != nil || normalized == "" {
return ClientMsg("invalid eprel base_url")
}
value = strings.TrimRight(normalized, "/")
}
}
doc, _, err := s.loadDoc(ctx)
if err != nil {
return err
}
if doc.Values == nil {
doc.Values = map[string]string{}
}
doc.Values[key] = value
if isSecretValueKey(key) && strings.TrimSpace(value) != "" {
enc, err := EncryptSecret(s.Key, strings.TrimSpace(value))
if err != nil {
return err
}
doc.Values[key] = enc
}
return s.saveDoc(ctx, doc)
}
@@ -0,0 +1,109 @@
package platformsettings
import (
"context"
"strings"
"testing"
"time"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
key := DeriveKey("test-platform-secret-key-material", "fallback")
if len(key) != 32 {
t.Fatalf("key len %d", len(key))
}
enc, err := EncryptSecret(key, "sk_live_secret_value")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(enc, encPrefix) {
t.Fatalf("expected enc prefix, got %q", enc)
}
plain, err := DecryptSecret(key, enc)
if err != nil {
t.Fatal(err)
}
if plain != "sk_live_secret_value" {
t.Fatalf("got %q", plain)
}
}
func TestParseDuration(t *testing.T) {
d, ok := parseDuration("10s")
if !ok || d != 10*time.Second {
t.Fatalf("10s -> %v ok=%v", d, ok)
}
d, ok = parseDuration("5")
if !ok || d != 5*time.Second {
t.Fatalf("5 -> %v ok=%v", d, ok)
}
}
func TestSecretValueKeys(t *testing.T) {
if !isSecretValueKey(KeyStripeSecretKey) {
t.Fatal("stripe secret should be secret")
}
if !isSecretValueKey(KeyEPRELAPIKey) {
t.Fatal("eprel api key should be secret")
}
if !isSecretValueKey(KeyPineconeAPIKey) {
t.Fatal("pinecone.api_key should be secret")
}
if !isSecretValueKey(ValueKeyResendAPIKey) {
t.Fatal("mail.resend_api_key should be secret")
}
if isSecretValueKey(KeyPineconeHost) {
t.Fatal("pinecone.host should not be secret")
}
if isSecretValueKey(KeyStripeMock) {
t.Fatal("stripe.mock should not be secret")
}
}
func TestAllowedValueKeys(t *testing.T) {
if !isAllowedValueKey(KeyEPRELFicheLanguage) {
t.Fatal("eprel.fiche_language must be allowed")
}
if isAllowedValueKey("evil.injection") {
t.Fatal("unknown keys must be rejected")
}
}
func TestNormalizeEPRELFicheLanguage(t *testing.T) {
got, err := NormalizeEPRELFicheLanguage(" de ")
if err != nil || got != "DE" {
t.Fatalf("got %q err=%v", got, err)
}
if _, err := NormalizeEPRELFicheLanguage("xx"); err == nil {
t.Fatal("expected error for unsupported language")
}
}
func TestResolvePinecone_envOnly(t *testing.T) {
svc := NewService(nil, EnvConfig{
PineconeAPIKey: "pc-env-key",
PineconeHost: "https://index.svc.pinecone.io",
PineconeNamespace: "ns-env",
})
got, err := svc.ResolvePinecone(context.Background())
if err != nil {
t.Fatal(err)
}
if !got.Configured() {
t.Fatal("expected configured")
}
if got.APIKey != "pc-env-key" || got.Host != "https://index.svc.pinecone.io" || got.Namespace != "ns-env" {
t.Fatalf("got %+v", got)
}
}
func TestResolvePinecone_unset(t *testing.T) {
svc := NewService(nil, EnvConfig{})
got, err := svc.ResolvePinecone(context.Background())
if err != nil {
t.Fatal(err)
}
if got.Configured() {
t.Fatalf("expected unset, got %+v", got)
}
}
+306
View File
@@ -0,0 +1,306 @@
package platformsettings
import "time"
// Source reports where a resolved value came from.
const (
SourceNone = "none"
SourceDB = "db"
SourceEnv = "env"
)
// Well-known Values keys (non-secret / stripe-eprel bag) and mail secret keys
// stored in the SMTP document (encrypted). Coordinate with Agent 2/3/4/6.
const (
ValueKeyResendAPIKey = "mail.resend_api_key" // legacy Values bag — prefer SMTP.Resend*
ValueKeyEmailDryRun = "mail.email_dry_run"
)
// EnvConfig carries encryption material and optional env fallbacks used when
// DB rows are empty (bootstrap / cutover).
type EnvConfig struct {
AppEncryptionKey string
CredentialsEncryptionKey string
TokenSigningSecret string
DatabaseURL string
OpenAIAPIKey string
OpenAIBaseURL string
OpenAIModel string
// Optional vectorization (embeddings) env bootstrap; falls back to OpenAI* when empty.
OpenAIEmbeddingAPIKey string
OpenAIEmbeddingBaseURL string
OpenAIEmbeddingModel string
SMTPEnabled bool
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
ResendAPIKey string
EmailDryRun bool
EmailDryRunSet bool // true when EMAIL_DRY_RUN was set explicitly in env
// Optional OAuth env fallbacks (not yet required by Config).
GoogleClientID string
GoogleClientSecret string
// Stripe / EPREL / feeds — env bootstrap; admin Values override when set.
StripeSecretKey string
StripeWebhookSecret string
StripeMock bool
StripePriceIDs map[string]string
EPRELEnabled bool
EPRELBaseURL string
EPRELTimeout time.Duration
EPRELFicheLanguage string
EPRELAPIKey string
PineconeAPIKey string
PineconeHost string
PineconeNamespace string
FeedPrivateAllowlist string
}
// AI role keys for platform multi-config (admin ai_roles map).
// ASK: no SQL migration — stored in company_settings JSON (SystemCompanyID).
// A dedicated platform_ai_roles table would need a goose migration if you
// later need SQL-level queries/indexes by role; say if you want that cutover.
const (
AIRoleProcessing = "processing"
AIRoleVectorization = "vectorization" // embeddings
// AIRoleDocsAPI is an admin-configurable slot for a future docs/API
// assistant. It must remain unused by the rule-based /docs Ask guide
// (apps/web DocsAskGuide / $lib/docs-guide) — that UI stays decision-tree only.
AIRoleDocsAPI = "docs_api"
// AIRoleSupport is an admin-configurable FUTURE slot for ticket assist.
// Admins may store provider/base_url/model/key here, but support-center
// APIs must not auto-reply with an LLM unless an explicit safe stub opts
// in (see support.TryAutoReplyLLM — currently always refuses). Guided
// /docs Ask stays rule-based and must never ResolveAIConfig this role.
AIRoleSupport = "support"
)
// AIRoles is the ordered catalog of known platform AI config roles.
var AIRoles = []string{
AIRoleProcessing,
AIRoleVectorization,
AIRoleDocsAPI,
AIRoleSupport,
}
// PublicView is the admin GET payload — secrets are never returned in full.
type PublicView struct {
OpenAI OpenAIPublic `json:"openai"` // legacy alias; prefer ai_roles.processing
AIConfigs map[string]AIConfigPublic `json:"ai_roles"`
SMTP SMTPPublic `json:"smtp"`
Mail MailPublic `json:"mail"` // flat alias for admin UI
OAuth OAuthPublic `json:"oauth"`
Values map[string]string `json:"values"`
Updated *string `json:"updated_at,omitempty"`
}
// OpenAIPublic is the masked OpenAI / platform AI key view (legacy single-slot).
type OpenAIPublic struct {
Configured bool `json:"configured"`
HasAPIKey bool `json:"has_api_key"`
APIKeyLast4 string `json:"api_key_last4,omitempty"`
APIKeyMasked string `json:"api_key_masked,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
Source string `json:"source"` // db | env | none
}
// AIConfigPublic is one role's masked admin view (api_key never returned in full).
type AIConfigPublic struct {
Role string `json:"role"`
Provider string `json:"provider,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Model string `json:"model,omitempty"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
HasAPIKey bool `json:"has_api_key"`
APIKeyLast4 string `json:"api_key_last4,omitempty"`
APIKeyMasked string `json:"api_key_masked,omitempty"`
Extras map[string]string `json:"extras,omitempty"`
Source string `json:"source"` // db | env | none
}
// SMTPPublic is the masked platform SMTP / Resend view.
type SMTPPublic struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"`
User string `json:"user,omitempty"`
From string `json:"from,omitempty"`
HasPassword bool `json:"has_password"`
PasswordLast4 string `json:"password_last4,omitempty"`
PasswordMasked string `json:"password_masked,omitempty"`
HasResendAPIKey bool `json:"has_resend_api_key"`
ResendAPIKeyLast4 string `json:"resend_api_key_last4,omitempty"`
ResendAPIKeyMasked string `json:"resend_api_key_masked,omitempty"`
EmailDryRun bool `json:"email_dry_run"`
Source string `json:"source"`
}
// MailPublic is a flat alias of SMTPPublic for admin UI field names.
type MailPublic struct {
Configured bool `json:"configured"`
SMTPEnabled bool `json:"smtp_enabled"`
SMTPHost string `json:"smtp_host,omitempty"`
SMTPPort string `json:"smtp_port,omitempty"`
SMTPUser string `json:"smtp_user,omitempty"`
SMTPFrom string `json:"smtp_from,omitempty"`
HasSMTPPassword bool `json:"has_smtp_password"`
SMTPPasswordMasked string `json:"smtp_password_masked,omitempty"`
HasResendAPIKey bool `json:"has_resend_api_key"`
ResendAPIKeyMasked string `json:"resend_api_key_masked,omitempty"`
EmailDryRun bool `json:"email_dry_run"`
Source string `json:"source"`
LastTestStatus string `json:"last_test_status,omitempty"`
}
// OAuthPublic groups OAuth providers (extensible).
type OAuthPublic struct {
Google GoogleOAuthPublic `json:"google"`
}
// GoogleOAuthPublic is the masked Google OAuth client view.
type GoogleOAuthPublic struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
ClientID string `json:"client_id,omitempty"`
HasClientSecret bool `json:"has_client_secret"`
ClientSecretLast4 string `json:"client_secret_last4,omitempty"`
ClientSecretMasked string `json:"client_secret_masked,omitempty"`
Source string `json:"source"`
}
// UpdateInput is the PUT body. Omitted / empty secrets keep existing ciphertext.
type UpdateInput struct {
OpenAI *OpenAIUpdate `json:"openai,omitempty"` // legacy; synced with ai_roles.processing
AIConfigs map[string]*AIConfigUpdate `json:"ai_roles,omitempty"`
SMTP *SMTPUpdate `json:"smtp,omitempty"`
Mail *MailUpdate `json:"mail,omitempty"` // flat alias → merged into SMTP
OAuth *OAuthUpdate `json:"oauth,omitempty"`
Values map[string]*string `json:"values,omitempty"` // nil pointer deletes key; empty string sets ""
}
// OpenAIUpdate patches platform OpenAI settings.
type OpenAIUpdate struct {
BaseURL *string `json:"base_url,omitempty"`
Model *string `json:"model,omitempty"`
APIKey *string `json:"api_key,omitempty"` // non-empty replaces; omit keeps
ClearAPIKey bool `json:"clear_api_key"`
}
// AIConfigUpdate patches one role. Omitted / empty api_key keeps ciphertext.
// Extras: omit keeps; key with null deletes; non-null sets (partial merge).
type AIConfigUpdate struct {
Provider *string `json:"provider,omitempty"`
BaseURL *string `json:"base_url,omitempty"`
Model *string `json:"model,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ClearAPIKey bool `json:"clear_api_key"`
Enabled *bool `json:"enabled,omitempty"`
Extras map[string]*string `json:"extras,omitempty"`
}
// SMTPUpdate patches platform SMTP / Resend settings.
type SMTPUpdate struct {
Enabled *bool `json:"enabled,omitempty"`
Host *string `json:"host,omitempty"`
Port *string `json:"port,omitempty"`
User *string `json:"user,omitempty"`
From *string `json:"from,omitempty"`
Password *string `json:"password,omitempty"`
ClearPassword bool `json:"clear_password"`
ResendAPIKey *string `json:"resend_api_key,omitempty"`
ClearResendAPIKey bool `json:"clear_resend_api_key"`
EmailDryRun *bool `json:"email_dry_run,omitempty"`
}
// MailUpdate is the flat admin-UI alias for SMTPUpdate.
type MailUpdate struct {
SMTPEnabled *bool `json:"smtp_enabled,omitempty"`
SMTPHost *string `json:"smtp_host,omitempty"`
SMTPPort *string `json:"smtp_port,omitempty"`
SMTPUser *string `json:"smtp_user,omitempty"`
SMTPFrom *string `json:"smtp_from,omitempty"`
SMTPPassword *string `json:"smtp_password,omitempty"`
ClearSMTPPassword bool `json:"clear_smtp_password"`
ResendAPIKey *string `json:"resend_api_key,omitempty"`
ClearResendAPIKey bool `json:"clear_resend_api_key"`
EmailDryRun *bool `json:"email_dry_run,omitempty"`
}
// OAuthUpdate patches OAuth providers.
type OAuthUpdate struct {
Google *GoogleOAuthUpdate `json:"google,omitempty"`
}
// GoogleOAuthUpdate patches Google OAuth client credentials.
type GoogleOAuthUpdate struct {
Enabled *bool `json:"enabled,omitempty"`
ClientID *string `json:"client_id,omitempty"`
ClientSecret *string `json:"client_secret,omitempty"`
ClearClientSecret bool `json:"clear_client_secret"`
}
// ResolvedOpenAI is the runtime OpenAI config (plaintext key — never log).
// Prefer ResolveAIConfig(AIRoleProcessing) for new callers.
type ResolvedOpenAI struct {
APIKey string
BaseURL string
Model string
Source string
}
// ResolvedAIConfig is runtime credentials for one AI role (plaintext key — never log).
type ResolvedAIConfig struct {
Role string
Provider string
APIKey string
BaseURL string
Model string
Enabled bool
Extras map[string]string
Source string
}
// ResolvedSMTP is the runtime SMTP config (plaintext password — never log).
type ResolvedSMTP struct {
Enabled bool
Host string
Port string
User string
Password string
From string
Source string
}
// ResolvedResend is the platform Resend API key (plaintext — never log).
type ResolvedResend struct {
APIKey string
Source string
}
// ResolvedEmailDryRun is the platform dry-run flag after settings merge.
type ResolvedEmailDryRun struct {
DryRun bool
Source string
}
// ResolvedOAuthGoogle is runtime Google OAuth (plaintext secret — never log).
type ResolvedOAuthGoogle struct {
Enabled bool
ClientID string
ClientSecret string
Source string
}