Changeset 0.9.4 (#54)

This commit is contained in:
2026-02-24 10:44:12 +00:00
parent 90021157e6
commit 5e416d3726
26 changed files with 1333 additions and 108 deletions

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
@@ -11,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
@@ -19,10 +21,11 @@ import (
type AdminHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewAdminHandler(s store.Stores) *AdminHandler {
return &AdminHandler{stores: s}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver) *AdminHandler {
return &AdminHandler{stores: s, vault: vault}
}
// ── User Management ─────────────────────────
@@ -254,7 +257,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
for i, cfg := range cfgs {
out[i] = configWithKey{
ProviderConfig: cfg,
HasKey: cfg.APIKeyEnc != "",
HasKey: cfg.HasKey(),
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
@@ -291,16 +294,32 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: models.JSONMap(req.Headers),
Settings: models.JSONMap(req.Settings),
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
IsPrivate: req.IsPrivate,
}
// Encrypt the API key
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
// No vault configured — store raw bytes (unencrypted fallback)
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
@@ -319,7 +338,7 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
"settings": cfg.Settings,
"is_active": cfg.IsActive,
"is_private": cfg.IsPrivate,
"has_key": cfg.APIKeyEnc != "",
"has_key": cfg.HasKey(),
"created_at": cfg.CreatedAt,
"updated_at": cfg.UpdatedAt,
})
@@ -339,9 +358,19 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
}
patch := req.ProviderConfigPatch
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
// Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
patch.APIKeyEnc = req.APIKey // TODO: encrypt
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
@@ -446,7 +475,21 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
// fetchModelsForProvider fetches and syncs models for a single provider config.
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg)
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
return 0, 0, 0, err
}