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

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -13,10 +14,11 @@ import (
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s}
func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s, vault: vault}
}
// ListConfigs returns configs accessible to the user (global + personal + team).
@@ -52,7 +54,7 @@ func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.APIKeyEnc != "",
HasKey: cfg.HasKey(),
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
@@ -111,13 +113,32 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal,
KeyScope: models.ScopePersonal,
OwnerID: &userID,
IsActive: true,
}
// Encrypt the API key with user's UEK (personal scope)
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
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
@@ -133,7 +154,7 @@ func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
"scope": cfg.Scope,
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey)
if err != nil {
// Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it.
@@ -169,9 +190,23 @@ func (h *ProviderConfigHandler) UpdateConfig(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, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
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 {
@@ -228,7 +263,21 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
return
}
result, err := syncAndEnableProviderModels(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, userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
} else {
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return