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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user