step 2: delete dropped packages and handlers

Removed 16 packages: tools, compaction, extraction, roles, mentions,
notelinks, export, memory, knowledge, providers, routing, capabilities,
filters, retention, workspace, treepath

Removed 45 handler files (chat, AI, tools, personas, providers,
model routing, usage tracking, summarization, title gen, streaming)

127 files deleted, ~37K lines removed.
Build will break — expected, fixed in steps 3-5.
This commit is contained in:
2026-03-25 19:52:08 -04:00
parent 11fd8c1e57
commit be6f9519f8
127 changed files with 0 additions and 37016 deletions

View File

@@ -1,297 +0,0 @@
package handlers
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
)
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
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).
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
userID := getUserID(c)
// User settings → Providers shows only personal (BYOK) configs.
// Global/team providers are managed by admins and surfaced via the model list.
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
// Mask API keys
type safeConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault string `json:"model_default,omitempty"`
Scope string `json:"scope"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
out := make([]safeConfig, len(cfgs))
for i, cfg := range cfgs {
out[i] = safeConfig{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// GetConfig returns a single config by ID (if user has access).
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
c.JSON(http.StatusOK, cfg)
}
// CreateConfig creates a personal provider config (BYOK).
// After creation, automatically fetches models from the provider API and enables them.
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
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
}
// Auto-fetch models from the provider API and enable them.
// The user added this key to USE it — don't make them hunt for a fetch button.
resp := gin.H{
"id": cfg.ID,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"scope": cfg.Scope,
}
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.
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
resp["models_fetched"] = 0
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
} else {
resp["models_fetched"] = result.Total
}
c.JSON(http.StatusCreated, resp)
}
// UpdateConfig updates a personal provider config.
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
return
}
// Bind standard fields
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → encrypted fields
if req.APIKey != nil && *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
}
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
// DeleteConfig deletes a personal provider config.
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
return
}
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ListModels returns models for a specific provider config.
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
id := c.Param("id")
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.
// This is the user-facing equivalent of admin POST /models/fetch.
// Allows refreshing models for existing BYOK providers.
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
// 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 {
if errors.Is(err, ErrUpstreamTimeout) {
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "models synced",
"added": result.Added,
"updated": result.Updated,
"total": result.Total,
})
}

View File

@@ -1,228 +0,0 @@
package handlers
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"strings"
// Register decoders so image.Decode works for common formats
_ "image/gif"
_ "image/jpeg"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
const avatarSize = 128
const maxUploadBytes = 2 * 1024 * 1024 // 2MB raw input limit
type uploadAvatarRequest struct {
Image string `json:"image" binding:"required"` // base64 data URI or raw base64
}
// ── Upload Avatar ───────────────────────────
// POST /api/v1/profile/avatar
// Accepts { "image": "data:image/png;base64,..." } or { "image": "<raw base64>" }
// Decodes, resizes to 128×128 PNG, stores as data URI.
func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
userID := getUserID(c)
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
// Strip data URI prefix if present
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
// Decode base64
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
// Decode image (supports PNG, JPEG, GIF via registered decoders)
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
// Resize to 128×128
resized := resizeBilinear(src, avatarSize, avatarSize)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
// Build data URI
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
// Store in DB
err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// ── Delete Avatar ───────────────────────────
// DELETE /api/v1/profile/avatar
func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
userID := getUserID(c)
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}
// ── Bilinear Resize ─────────────────────────
// Pure stdlib bilinear interpolation. Quality is fine for 128×128 avatars.
func resizeBilinear(src image.Image, w, h int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
sb := src.Bounds()
sw := float64(sb.Dx())
sh := float64(sb.Dy())
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Map destination pixel to source coordinates
sx := (float64(x) + 0.5) * sw / float64(w) - 0.5
sy := (float64(y) + 0.5) * sh / float64(h) - 0.5
x0 := int(sx)
y0 := int(sy)
xf := sx - float64(x0)
yf := sy - float64(y0)
// Clamp
if x0 < sb.Min.X { x0 = sb.Min.X }
if y0 < sb.Min.Y { y0 = sb.Min.Y }
x1 := x0 + 1
y1 := y0 + 1
if x1 >= sb.Max.X { x1 = sb.Max.X - 1 }
if y1 >= sb.Max.Y { y1 = sb.Max.Y - 1 }
// Sample 4 neighbors
c00 := src.At(x0, y0)
c10 := src.At(x1, y0)
c01 := src.At(x0, y1)
c11 := src.At(x1, y1)
dst.Set(x, y, bilinearMix(c00, c10, c01, c11, xf, yf))
}
}
return dst
}
func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
r00, g00, b00, a00 := c00.RGBA()
r10, g10, b10, a10 := c10.RGBA()
r01, g01, b01, a01 := c01.RGBA()
r11, g11, b11, a11 := c11.RGBA()
mix := func(v00, v10, v01, v11 uint32) uint8 {
top := float64(v00)*(1-xf) + float64(v10)*xf
bot := float64(v01)*(1-xf) + float64(v11)*xf
return uint8((top*(1-yf) + bot*yf) / 256)
}
return color.RGBA{
R: mix(r00, r10, r01, r11),
G: mix(g00, g10, g01, g11),
B: mix(b00, b10, b01, b11),
A: mix(a00, a10, a01, a11),
}
}
// ── Persona Avatar Upload ────────────────────
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
// UploadPersonaAvatar uploads and resizes a persona avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
resized := resizeBilinear(src, avatarSize, avatarSize)
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// DeletePersonaAvatar clears a persona's avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
empty := ""
err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}

View File

@@ -1,142 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/auth"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/store"
)
// ModelHandler provides the unified models endpoint.
type ModelHandler struct {
stores store.Stores
healthStore HealthStatusQuerier // provider health for status enrichment (v0.22.3, nil = disabled)
}
func NewModelHandler(s store.Stores) *ModelHandler {
return &ModelHandler{stores: s}
}
// SetHealthStore attaches the health store for model status enrichment (v0.22.3).
func (h *ModelHandler) SetHealthStore(hs HealthStatusQuerier) {
h.healthStore = hs
}
// ListEnabledModels returns all models the user can access (catalog + personas),
// with user preferences (hidden, sort order) applied.
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID)
if err != nil {
log.Printf("error: ModelsForUser(%s): %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"})
return
}
// Enrich with provider health status (v0.22.3)
if h.healthStore != nil {
healthMap := h.buildHealthMap(c.Request.Context())
for i := range userModels {
if s, ok := healthMap[userModels[i].ProviderConfigID]; ok {
userModels[i].ProviderStatus = s
}
}
}
// Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
// Model allowlist filtering (v0.24.2) — non-admin users with group-level
// model restrictions only see permitted models. Persona entries whose
// underlying model is disallowed are also filtered.
role, _ := c.Get("role")
if role != "admin" {
allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID)
if err == nil && allowlist != nil {
filtered := make([]models.UserModel, 0, len(userModels))
for _, m := range userModels {
if allowlist[m.ModelID] {
filtered = append(filtered, m)
}
}
userModels = filtered
}
}
c.JSON(http.StatusOK, gin.H{"data": userModels, "default_model": defaultModel})
}
// buildHealthMap returns a map of configID → ProviderStatus from current health windows.
func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.ProviderStatus {
m := make(map[string]models.ProviderStatus)
windows, err := h.healthStore.ListAllCurrent(ctx)
if err != nil {
return m
}
for _, w := range windows {
m[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
}
return m
}
// ResolveModelCaps is the canonical capability resolver for any model.
// v0.29.0: accepts CatalogStore instead of using database.DB directly.
func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
caps, ok := capsFromCatalog(catalog, modelID, configID)
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
}
// 2. Any provider: same model_id, any config
caps, ok := capsFromCatalog(catalog, modelID, "")
if ok {
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// 3. Heuristic inference
caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// capsFromCatalog looks up capabilities from the model_catalog table.
func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) {
if catalog == nil {
return models.ModelCapabilities{}, false
}
var capsJSON []byte
var err error
if configID != "" {
capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID)
} else {
capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID)
}
if err != nil || len(capsJSON) == 0 {
return models.ModelCapabilities{}, false
}
var caps models.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return models.ModelCapabilities{}, false
}
// Merge with known data to fill gaps
resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil)
return resolved, true
}

View File

@@ -1,240 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Channel Models Handler ──────────────────
// Manages multi-model assignments per channel (v0.20.0 Phase 2).
// Routes:
// GET /channels/:id/models — list
// POST /channels/:id/models — add
// PATCH /channels/:id/models/:modelId — update (display_name, is_default, system_prompt)
// DELETE /channels/:id/models/:modelId — remove
const maxModelsPerChannel = 5
type ChannelModelHandler struct {
stores store.Stores
}
func NewChannelModelHandler(stores store.Stores) *ChannelModelHandler {
return &ChannelModelHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ChannelModelHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
roster, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
if roster == nil {
roster = []models.ChannelModel{}
}
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Add ──────────────────────────────────────
type addChannelModelRequest struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id"`
DisplayName string `json:"display_name" binding:"required"`
SystemPrompt string `json:"system_prompt"`
IsDefault bool `json:"is_default"`
}
func (h *ChannelModelHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req addChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check max models limit
existing, err := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check existing models"})
return
}
if len(existing) >= maxModelsPerChannel {
c.JSON(http.StatusConflict, gin.H{"error": "maximum models per channel reached"})
return
}
// If this is the first model or marked as default, ensure only one default
isDefault := req.IsDefault || len(existing) == 0
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: req.ModelID,
ProviderConfigID: req.ProviderConfigID,
DisplayName: req.DisplayName,
SystemPrompt: req.SystemPrompt,
IsDefault: isDefault,
}
// SetModel handles INSERT ON CONFLICT (upsert by channel_id + model_id)
if err := h.stores.Channels.SetModel(c.Request.Context(), cm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add model"})
return
}
// If this is the new default, clear default on others
if isDefault {
h.clearOtherDefaults(c, channelID, req.ModelID)
}
// Return the updated roster
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusCreated, gin.H{"data": roster})
}
// ── Update ───────────────────────────────────
type updateChannelModelRequest struct {
DisplayName *string `json:"display_name"`
SystemPrompt *string `json:"system_prompt"`
IsDefault *bool `json:"is_default"`
}
func (h *ChannelModelHandler) Update(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
var req updateChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
fields := make(map[string]interface{})
if req.DisplayName != nil {
fields["display_name"] = *req.DisplayName
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.IsDefault != nil {
fields["is_default"] = *req.IsDefault
if *req.IsDefault {
h.clearOtherDefaults(c, channelID, cm.ModelID)
}
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Channels.UpdateModel(c.Request.Context(), modelRecordID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Delete ───────────────────────────────────
func (h *ChannelModelHandler) Delete(c *gin.Context) {
channelID := c.Param("id")
modelRecordID := c.Param("modelId")
userID := getUserID(c)
if !userOwnsChannel(c, channelID, userID) {
return
}
// Verify model belongs to this channel
cm, err := h.stores.Channels.GetModelByID(c.Request.Context(), modelRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up model"})
return
}
if cm.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "model does not belong to this channel"})
return
}
if err := h.stores.Channels.DeleteModel(c.Request.Context(), modelRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
// If deleted model was default, promote the first remaining model
if cm.IsDefault {
remaining, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if len(remaining) > 0 {
h.stores.Channels.UpdateModel(c.Request.Context(), remaining[0].ID,
map[string]interface{}{"is_default": true})
}
}
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
if roster == nil { roster = []models.ChannelModel{} }
c.JSON(http.StatusOK, gin.H{"data": roster})
}
// ── Helpers ──────────────────────────────────
// clearOtherDefaults sets is_default=false on all models in the channel
// except the one with the given modelID.
func (h *ChannelModelHandler) clearOtherDefaults(c *gin.Context, channelID, keepModelID string) {
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
for _, m := range roster {
if m.ModelID != keepModelID && m.IsDefault {
h.stores.Channels.UpdateModel(c.Request.Context(), m.ID,
map[string]interface{}{"is_default": false})
}
}
}

View File

@@ -1,641 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Request / Response types ────────────────
type createChannelRequest struct {
Title string `json:"title" binding:"required,max=500"`
Type string `json:"type,omitempty"` // direct (default), dm, group, channel, workflow
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
// v0.23.2: DM creation
Participants []string `json:"participants,omitempty"` // user IDs for DM (exactly 1 other user)
AiMode string `json:"ai_mode,omitempty"` // auto (default), mention_only, off
}
type updateChannelRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
FolderID *string `json:"folder_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
WorkspaceID *string `json:"workspace_id,omitempty"` // bind workspace (v0.21.5)
AiMode *string `json:"ai_mode,omitempty"` // v0.23.2: auto, mention_only, off
Topic *string `json:"topic,omitempty"` // v0.23.2: channel topic
}
type channelResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Type string `json:"type"`
AiMode string `json:"ai_mode,omitempty"`
Topic *string `json:"topic,omitempty"`
Description *string `json:"description"`
Model *string `json:"model"`
ProviderConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
Folder *string `json:"folder"`
FolderID *string `json:"folder_id,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
WorkspaceID *string `json:"workspace_id,omitempty"`
Tags []string `json:"tags"`
Settings json.RawMessage `json:"settings,omitempty"`
MessageCount int `json:"message_count"`
UnreadCount int `json:"unread_count,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
// ChannelHandler holds dependencies for channel endpoints.
type ChannelHandler struct {
stores store.Stores
}
// NewChannelHandler creates a new channel handler.
func NewChannelHandler(stores store.Stores) *ChannelHandler {
return &ChannelHandler{stores: stores}
}
// channelDeleteHook is called after a channel is successfully deleted.
// Set at startup by SetChannelDeleteHook to clean up storage files.
var channelDeleteHook func(channelID string)
// SetChannelDeleteHook registers a callback invoked after channel deletion.
// Used to clean up channel files on the storage backend.
func SetChannelDeleteHook(fn func(channelID string)) {
channelDeleteHook = fn
}
// ── Helpers ─────────────────────────────────
// getUserID extracts the authenticated user's ID from context.
func getUserID(c *gin.Context) string {
uid, _ := c.Get("user_id")
s, _ := uid.(string)
return s
}
// isSessionAuth returns true if the request is from a session participant (v0.24.3).
func isSessionAuth(c *gin.Context) bool {
return c.GetString("auth_type") == "session"
}
// sessionCanAccessChannel validates that a session participant is authorized
// for the given channel.
func sessionCanAccessChannel(c *gin.Context, channelID string) bool {
if !isSessionAuth(c) {
return false
}
return c.GetString("channel_id") == channelID
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 100 {
perPage = 100
}
offset = (page - 1) * perPage
return
}
// listItemToResponse converts a store.ChannelListItem to a handler response.
func listItemToResponse(item store.ChannelListItem) channelResponse {
tags := item.Tags
if tags == nil {
tags = []string{}
}
return channelResponse{
ID: item.ID,
UserID: item.UserID,
Title: item.Title,
Type: item.Type,
AiMode: item.AiMode,
Topic: item.Topic,
Description: item.Description,
Model: item.Model,
ProviderConfigID: item.ProviderConfigID,
SystemPrompt: item.SystemPrompt,
IsArchived: item.IsArchived,
IsPinned: item.IsPinned,
Folder: item.Folder,
FolderID: item.FolderID,
ProjectID: item.ProjectID,
WorkspaceID: item.WorkspaceID,
Tags: tags,
Settings: item.Settings,
MessageCount: item.MessageCount,
UnreadCount: item.UnreadCount,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// ── List Channels ───────────────────────────
func (h *ChannelHandler) ListChannels(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Optional filters
archived := c.DefaultQuery("archived", "false")
var channelTypes []string
if raw := c.Query("types"); raw != "" {
for _, t := range strings.Split(raw, ",") {
t = strings.TrimSpace(t)
if t != "" {
channelTypes = append(channelTypes, t)
}
}
}
if len(channelTypes) == 0 {
if ct := c.DefaultQuery("type", ""); ct != "" {
channelTypes = []string{ct}
}
}
filter := store.ChannelListFilter{
ListOptions: store.ListOptions{Limit: perPage, Offset: offset},
Archived: archived == "true",
Types: channelTypes,
Folder: c.Query("folder"),
FolderID: c.Query("folder_id"),
Search: strings.TrimSpace(c.Query("search")),
ProjectID: c.Query("project_id"),
}
items, total, err := h.stores.Channels.ListFiltered(c.Request.Context(), userID, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
channels := make([]channelResponse, 0, len(items))
for _, item := range items {
channels = append(channels, listItemToResponse(item))
}
// Resolve DM titles: show the other participant's name, not the creator's label
resolveDMTitles(c.Request.Context(), channels, userID)
SafeJSON(c, http.StatusOK, paginatedResponse{
Data: channels,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Create Channel ──────────────────────────
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
userID := getUserID(c)
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Tags == nil {
req.Tags = []string{}
}
// Default type is "direct" (1:1 AI chat)
channelType := req.Type
if channelType == "" {
channelType = "direct"
}
// Default ai_mode: mention_only for DMs, auto for everything else
aiMode := req.AiMode
if aiMode == "" {
if channelType == "dm" {
aiMode = "mention_only"
} else {
aiMode = "auto"
}
}
ctx := c.Request.Context()
// ── DM dedup: check for existing DM between these two users ──
if channelType == "dm" && len(req.Participants) == 1 {
otherUserID := req.Participants[0]
if otherUserID == userID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create DM with yourself"})
return
}
existingID, _ := h.stores.Channels.FindExistingDM(ctx, userID, otherUserID)
if existingID != "" {
// Return existing channel instead of creating duplicate
item, err := h.stores.Channels.GetForUser(ctx, existingID, userID)
if err == nil {
SafeJSON(c, http.StatusOK, listItemToResponse(*item)) // 200, not 201 — existing resource
return
}
// If read fails, fall through and create new (shouldn't happen)
}
}
// Build channel model
ch := &models.Channel{
UserID: userID,
Title: req.Title,
Type: channelType,
Description: req.Description,
Model: req.Model,
SystemPrompt: req.SystemPrompt,
ProviderConfigID: req.ProviderConfigID,
FolderID: req.FolderID,
}
dmPartners := []string{}
if channelType == "dm" {
dmPartners = req.Participants
}
defaultConfigID := ""
if req.ProviderConfigID != nil {
defaultConfigID = *req.ProviderConfigID
}
if err := h.stores.Channels.CreateFull(ctx, ch, req.Folder, req.Tags, aiMode,
userID, dmPartners, req.Model, defaultConfigID); err != nil {
log.Printf("[channels] CreateFull error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
return
}
// Fetch full response with message count
item, err := h.stores.Channels.GetForUser(ctx, ch.ID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read created channel"})
return
}
SafeJSON(c, http.StatusCreated, listItemToResponse(*item))
}
// ── Get Channel ─────────────────────────────
func (h *ChannelHandler) GetChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
item, err := h.stores.Channels.GetForUser(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
SafeJSON(c, http.StatusOK, listItemToResponse(*item))
}
// ── Update Channel ──────────────────────────
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
ctx := c.Request.Context()
var req updateChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership (participants can only move to folder)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !owns {
// Participants may move channels to their own folders
folderOnly := req.FolderID != nil && req.Title == nil && req.Description == nil &&
req.Model == nil && req.SystemPrompt == nil && req.ProviderConfigID == nil &&
req.IsArchived == nil && req.IsPinned == nil && req.Folder == nil &&
req.Tags == nil && req.WorkspaceID == nil && req.AiMode == nil &&
req.Topic == nil && req.Settings == nil
if !folderOnly {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// Verify they are a participant
canAccess, _ := h.stores.Channels.UserCanAccess(ctx, channelID, userID)
if !canAccess {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
}
// Build fields map for store.Update
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Description != nil {
fields["description"] = *req.Description
}
if req.Model != nil {
fields["model"] = *req.Model
}
if req.SystemPrompt != nil {
fields["system_prompt"] = *req.SystemPrompt
}
if req.ProviderConfigID != nil {
if *req.ProviderConfigID == "" {
fields["provider_config_id"] = nil
} else {
fields["provider_config_id"] = *req.ProviderConfigID
}
}
if req.IsArchived != nil {
fields["is_archived"] = *req.IsArchived
}
if req.IsPinned != nil {
fields["is_pinned"] = *req.IsPinned
}
if req.Folder != nil {
fields["folder"] = *req.Folder
}
if req.FolderID != nil {
if *req.FolderID == "" {
fields["folder_id"] = nil // unbind from folder
} else {
fields["folder_id"] = *req.FolderID
}
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if req.WorkspaceID != nil {
if *req.WorkspaceID == "" {
fields["workspace_id"] = nil // unbind
} else {
fields["workspace_id"] = *req.WorkspaceID
}
}
if req.AiMode != nil {
mode := *req.AiMode
if mode != "auto" && mode != "mention_only" && mode != "off" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ai_mode must be auto, mention_only, or off"})
return
}
fields["ai_mode"] = mode
}
if req.Topic != nil {
fields["topic"] = *req.Topic
}
hasFields := len(fields) > 0
hasSettings := req.Settings != nil
if !hasFields && !hasSettings {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if hasSettings {
if !json.Valid([]byte(*req.Settings)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "settings must be valid JSON"})
return
}
}
if hasFields {
if err := h.stores.Channels.Update(ctx, channelID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
if hasSettings {
if err := h.stores.Channels.MergeSettings(ctx, channelID, json.RawMessage(*req.Settings)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update settings"})
return
}
}
// Return updated channel
h.GetChannel(c)
}
// ── Delete Channel ──────────────────────────
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
ctx := c.Request.Context()
// Check ownership first (without deleting)
owns, err := h.stores.Channels.UserOwns(ctx, channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if !owns {
// Not the owner — if participant, leave the channel instead of deleting.
res, _ := database.DB.ExecContext(ctx, database.Q(`
DELETE FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user' AND participant_id = $2
`), channelID, userID)
if rows, _ := res.RowsAffected(); rows > 0 {
c.JSON(http.StatusOK, gin.H{"message": "left channel"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Check if retention policy applies (global/team provider + TTL > 0)
if h.shouldRetain(ctx, channelID) {
ttl := h.retentionTTL(ctx)
purgeAfter := time.Now().Add(time.Duration(ttl) * 24 * time.Hour)
if err := h.stores.Channels.ArchiveForRetention(ctx, channelID, purgeAfter); err != nil {
log.Printf("[retention] ArchiveForRetention(%s) error: %v", channelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to archive channel"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "channel archived for retention",
"purge_after": purgeAfter.UTC().Format("2006-01-02T15:04:05Z"),
})
return
}
// Exempt — hard delete
n, err := h.stores.Channels.DeleteByOwner(ctx, channelID, userID)
if err != nil || n == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if channelDeleteHook != nil {
go channelDeleteHook(channelID)
}
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
}
// shouldRetain returns true if a channel uses a global or team provider
// and the retention TTL is configured (> 0).
func (h *ChannelHandler) shouldRetain(ctx context.Context, channelID string) bool {
ttl := h.retentionTTL(ctx)
if ttl <= 0 {
return false
}
// Only BYOK (personal) provider channels are exempt.
// All others — global, team, or no explicit provider — follow retention.
var providerConfigID *string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT provider_config_id FROM channels WHERE id = $1
`), channelID).Scan(&providerConfigID)
if providerConfigID == nil || *providerConfigID == "" {
return true // no explicit provider → defaults to global → retain
}
cfg, err := h.stores.Providers.GetByID(ctx, *providerConfigID)
if err != nil {
return true // provider deleted (FK SET NULL already handled above) → retain
}
return cfg.Scope != models.ScopePersonal
}
// retentionTTL reads the configured retention TTL in days from global settings.
func (h *ChannelHandler) retentionTTL(ctx context.Context) int {
cfg, err := h.stores.GlobalConfig.Get(ctx, "retention_ttl_days")
if err != nil {
return 0
}
if v, ok := cfg["value"].(float64); ok {
return int(v)
}
return 0
}
// ── Mark Read (v0.23.2) ───────────────────────
func (h *ChannelHandler) MarkRead(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
err := h.stores.Channels.MarkRead(c.Request.Context(), channelID, userID)
if err != nil {
// Participant row may not exist for legacy direct chats — that's OK
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── DM Title Resolution ──────────────────────
// resolveDMTitles replaces DM channel titles with the other participant's
// display name so each user sees "DM <other_person>" instead of the
// creator's original label.
func resolveDMTitles(ctx context.Context, channels []channelResponse, viewerID string) {
// Collect DM channel IDs
var dmIDs []string
dmIdx := map[string]int{} // channel_id → index in channels slice
for i, ch := range channels {
if ch.Type == "dm" {
dmIDs = append(dmIDs, ch.ID)
dmIdx[ch.ID] = i
}
}
if len(dmIDs) == 0 {
return
}
// Query: for each DM, get the OTHER participant's display name
// Uses channel_participants to find the peer, then joins users for name
placeholders := make([]string, len(dmIDs))
args := make([]interface{}, 0, len(dmIDs)+1)
for i, id := range dmIDs {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, viewerID)
viewerPlaceholder := fmt.Sprintf("$%d", len(args))
query := database.Q(fmt.Sprintf(`
SELECT cp.channel_id,
COALESCE(NULLIF(u.display_name, ''), u.username, 'Unknown')
FROM channel_participants cp
JOIN users u ON u.id = cp.participant_id
WHERE cp.channel_id IN (%s)
AND cp.participant_type = 'user'
AND cp.participant_id != %s
LIMIT %d
`, strings.Join(placeholders, ","), viewerPlaceholder, len(dmIDs)))
rows, err := database.DB.QueryContext(ctx, query, args...)
if err != nil {
return // non-fatal: keep original titles
}
defer rows.Close()
for rows.Next() {
var chID, peerName string
if rows.Scan(&chID, &peerName) == nil {
if idx, ok := dmIdx[chID]; ok {
channels[idx].Title = "DM " + peerName
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,324 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/providers"
capspkg "switchboard-core/capabilities"
)
// maxChainDepth limits AI-to-AI chaining to prevent runaway loops.
const maxChainDepth = 5
// chainIfMentioned checks if an assistant response @mentions another
// persona and triggers a follow-up completion. Uses resolveMention()
// directly — no roster or participant list needed. Works in any chat.
//
// Runs asynchronously (goroutine) after the SSE stream closes.
// The follow-up response is delivered to the client via WebSocket events.
func (h *CompletionHandler) chainIfMentioned(
channelID, userID, currentPersonaID, responseContent string,
depth int,
) {
if depth >= maxChainDepth {
return
}
defer func() {
if r := recover(); r != nil {
log.Printf("[chain] PANIC in chain (depth=%d): %v", depth, r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// Extract the @token for logging
token := extractFirstMention(responseContent)
if token == "" {
log.Printf("[chain] No @mention found in response (len=%d, persona=%s)", len(responseContent), currentPersonaID[:min(8, len(currentPersonaID))])
return
}
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
// Use the same resolveMention() that handles user @mentions
mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return
}
if mentionPersona == nil {
log.Printf("[chain] @%s resolved to raw model %s (no chain for raw models)", token, mentionModel)
return // no persona @mention found, or it's a raw model (no chain for those)
}
// Don't chain to self
if mentionPersona.ID == currentPersonaID {
log.Printf("[chain] @%s is self-mention, skipping", token)
return
}
log.Printf("[chain] %s @mentioned %s (depth=%d)", currentPersonaID[:min(8, len(currentPersonaID))], mentionPersona.Name, depth+1)
// Emit typing indicator
if h.hub != nil {
h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, true)
defer h.emitTyping(userID, channelID, mentionPersona.ID, mentionPersona.Name, false)
}
// Brief pause for UX
time.Sleep(500 * time.Millisecond)
// Resolve provider config
configID := mentionConfig
chainReq := completionRequest{
ChannelID: channelID,
Model: mentionModel,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain] Failed to resolve config for %s: %v", mentionPersona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
log.Printf("[chain] Provider %s unavailable: %v", providerID, err)
return
}
// Load conversation with persona's system prompt
messages, err := h.loadConversation(channelID, userID, mentionPersona.SystemPrompt, mentionPersona.ID, model)
if err != nil {
log.Printf("[chain] Failed to load conversation: %v", err)
return
}
// Context boundary
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style and mannerisms. Respond only as yourself per your system prompt.]",
mentionPersona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if mentionPersona.Temperature != nil {
provReq.Temperature = mentionPersona.Temperature
}
if mentionPersona.ThinkingBudget != nil && *mentionPersona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *mentionPersona.ThinkingBudget
}
// Apply provider-specific hooks
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
// Synchronous completion — result delivered via WebSocket
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
log.Printf("[chain] Completion failed for %s: %v", mentionPersona.Name, err)
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
// Persist
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, mentionPersona.ID)
if err != nil {
log.Printf("[chain] Failed to persist chained message: %v", err)
return
}
// Deliver via WebSocket to all channel participants
if h.hub != nil {
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", mentionPersona.ID)
}
// Log usage
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain] ✅ %s responded (depth=%d, %d tokens) in channel %s",
mentionPersona.Name, depth+1, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
// Recursive: check if this response @mentions yet another persona
h.chainIfMentioned(channelID, userID, mentionPersona.ID, resp.Content, depth+1)
}
// chainToPersona triggers a completion for a specific persona by ID.
// Used by @all fan-out. Does NOT recurse (depth-1 only).
func (h *CompletionHandler) chainToPersona(channelID, userID, personaID, triggerContent string, depth int) {
defer func() {
if r := recover(); r != nil {
log.Printf("[chain-all] PANIC: %v", r)
}
}()
persona := ResolvePersona(h.stores, personaID, userID)
if persona == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
if h.hub != nil {
h.emitTyping(userID, channelID, persona.ID, persona.Name, true)
defer h.emitTyping(userID, channelID, persona.ID, persona.Name, false)
}
configID := ""
if persona.ProviderConfigID != nil {
configID = *persona.ProviderConfigID
}
chainReq := completionRequest{
ChannelID: channelID,
Model: persona.BaseModelID,
ProviderConfigID: configID,
}
providerCfg, providerID, model, resolvedConfigID, _, err := h.resolveConfig(userID, channelID, chainReq)
if err != nil {
log.Printf("[chain-all] Config failed for %s: %v", persona.Name, err)
return
}
configID = resolvedConfigID
provider, err := providers.Get(providerID)
if err != nil {
return
}
messages, err := h.loadConversation(channelID, userID, persona.SystemPrompt, persona.ID, model)
if err != nil {
return
}
boundary := fmt.Sprintf(
"[You are now %s. Previous assistant messages may be from different AI personas — ignore their style. Respond only as yourself per your system prompt.]",
persona.Name,
)
messages = append(messages, providers.Message{Role: "system", Content: boundary})
caps := capspkg.ResolveIntrinsic(model, nil, nil)
provReq := providers.CompletionRequest{
Model: model,
Messages: messages,
MaxTokens: capspkg.ResolveMaxOutput(model, caps),
}
if persona.Temperature != nil {
provReq.Temperature = persona.Temperature
}
if persona.ThinkingBudget != nil && *persona.ThinkingBudget > 0 {
if providerCfg.Settings == nil {
providerCfg.Settings = make(map[string]interface{})
}
providerCfg.Settings["extended_thinking"] = true
providerCfg.Settings["thinking_budget"] = *persona.ThinkingBudget
}
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
callStart := time.Now()
resp, err := provider.ChatCompletion(ctx, providerCfg, provReq)
latencyMs := int(time.Since(callStart).Milliseconds())
if err != nil {
if h.health != nil {
h.health.RecordError(configID, latencyMs, err.Error())
}
return
}
if h.health != nil {
h.health.RecordSuccess(configID, latencyMs)
}
if resp.Content == "" {
return
}
msgID, err := h.persistMessage(channelID, userID, "assistant", resp.Content, model,
resp.InputTokens, resp.OutputTokens, nil, nil, configID, persona.ID)
if err != nil {
return
}
if h.hub != nil {
broadcastAssistantMessage(context.Background(), h.hub, channelID, msgID, resp.Content, model, "", persona.ID)
}
if h.stores.Usage != nil {
entry := &models.UsageEntry{
ChannelID: &channelID,
UserID: userID,
ProviderConfigID: &configID,
ProviderScope: "global",
ModelID: model,
PromptTokens: resp.InputTokens,
CompletionTokens: resp.OutputTokens,
}
_ = h.stores.Usage.Log(ctx, entry)
}
log.Printf("[chain-all] ✅ %s responded (%d tokens) in channel %s",
persona.Name, resp.InputTokens+resp.OutputTokens, channelID[:min(8, len(channelID))])
}
// emitTyping sends a typing indicator via WebSocket.
func (h *CompletionHandler) emitTyping(userID, channelID, participantID, displayName string, start bool) {
eventType := "typing.start"
if !start {
eventType = "typing.stop"
}
payload, _ := json.Marshal(map[string]string{
"channel_id": channelID,
"participant_id": participantID,
"participant_type": "persona",
"display_name": displayName,
})
h.hub.PublishToUser(userID, events.Event{
Label: eventType,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}

View File

@@ -1,118 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"runtime"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/database"
"switchboard-core/events"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Dashboard Admin Handler ─────────────────
// GET /api/v1/admin/dashboard
// Aggregates live operational data for the built-in admin monitoring page.
var processStartTime = time.Now()
type DashboardAdminHandler struct {
stores store.Stores
healthStore health.Store
hub *events.Hub
}
func NewDashboardAdminHandler(stores store.Stores, hs health.Store, hub *events.Hub) *DashboardAdminHandler {
return &DashboardAdminHandler{stores: stores, healthStore: hs, hub: hub}
}
type runtimeStats struct {
Goroutines int `json:"goroutines"`
HeapMB int `json:"heap_mb"`
SysMB int `json:"sys_mb"`
NumGC uint32 `json:"num_gc"`
GoVersion string `json:"go_version"`
}
func (h *DashboardAdminHandler) GetDashboard(c *gin.Context) {
ctx := c.Request.Context()
// Provider health summaries
var providerHealth []models.ProviderHealthSummary
windows, err := h.healthStore.ListAllCurrent(ctx)
if err == nil {
for _, w := range windows {
providerHealth = append(providerHealth, models.ProviderHealthSummary{
ProviderConfigID: w.ProviderConfigID,
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(),
ErrorCount: w.ErrorCount,
RateLimitCount: w.RateLimitCount,
TimeoutCount: w.TimeoutCount,
AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError,
LastErrorAt: w.LastErrorAt,
})
}
}
// 24h usage totals
since := time.Now().Add(-24 * time.Hour)
totals, _ := h.stores.Usage.GetTotals(ctx, store.UsageQueryOptions{
Since: &since,
})
// DB pool stats
var dbPool *sql.DBStats
if database.DB != nil {
stats := database.DB.Stats()
dbPool = &stats
}
// WebSocket connections
wsCount := 0
if h.hub != nil {
wsCount = h.hub.ConnCount()
}
// Recent errors from audit log (last 10 error-related entries)
var recentErrors []models.AuditEntry
entries, _, auditErr := h.stores.Audit.List(ctx, store.AuditListOptions{
ListOptions: store.ListOptions{Limit: 10},
ResourceType: "error",
})
if auditErr == nil {
recentErrors = entries
}
// Go runtime stats
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
rt := runtimeStats{
Goroutines: runtime.NumGoroutine(),
HeapMB: int(mem.HeapAlloc / 1024 / 1024),
SysMB: int(mem.Sys / 1024 / 1024),
NumGC: mem.NumGC,
GoVersion: runtime.Version(),
}
// Uptime
uptime := time.Since(processStartTime).Truncate(time.Second).String()
SafeJSON(c, http.StatusOK, gin.H{
"provider_health": providerHealth,
"usage_24h": totals,
"db_pool": dbPool,
"ws_connections": wsCount,
"recent_errors": recentErrors,
"runtime": rt,
"uptime": uptime,
})
}

View File

@@ -1,110 +0,0 @@
package handlers
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// ExportHandler converts markdown content to PDF or DOCX via pandoc.
type ExportHandler struct{}
func NewExportHandler() *ExportHandler { return &ExportHandler{} }
type exportRequest struct {
Content string `json:"content" binding:"required"`
Format string `json:"format" binding:"required"` // "pdf" or "docx"
Filename string `json:"filename"` // optional base name
}
// Convert handles POST /api/v1/export.
// Converts markdown content to the requested format and returns the file.
func (h *ExportHandler) Convert(c *gin.Context) {
var req exportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate format
switch req.Format {
case "pdf", "docx":
// ok
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be pdf or docx"})
return
}
// Check pandoc availability
if _, err := exec.LookPath("pandoc"); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "pandoc is not installed on the server"})
return
}
// Create temp dir for conversion
tmpDir, err := os.MkdirTemp("", "export-*")
if err != nil {
log.Printf("export: failed to create temp dir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
defer os.RemoveAll(tmpDir)
// Write markdown to temp file
inputPath := filepath.Join(tmpDir, "input.md")
if err := os.WriteFile(inputPath, []byte(req.Content), 0644); err != nil {
log.Printf("export: failed to write input: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "export failed"})
return
}
// Determine output filename
baseName := "document"
if req.Filename != "" {
baseName = strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
}
outputFile := baseName + "." + req.Format
outputPath := filepath.Join(tmpDir, outputFile)
// Build pandoc command
args := []string{inputPath, "-o", outputPath, "--standalone"}
if req.Format == "pdf" {
// Try to use a lightweight PDF engine
for _, engine := range []string{"weasyprint", "wkhtmltopdf", "pdflatex"} {
if _, err := exec.LookPath(engine); err == nil {
args = append(args, "--pdf-engine="+engine)
break
}
}
}
cmd := exec.CommandContext(c.Request.Context(), "pandoc", args...)
cmd.Dir = tmpDir
if output, err := cmd.CombinedOutput(); err != nil {
log.Printf("export: pandoc failed: %v\n%s", err, string(output))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pandoc conversion failed",
"details": string(output),
})
return
}
// Serve the file
contentType := "application/octet-stream"
switch req.Format {
case "pdf":
contentType = "application/pdf"
case "docx":
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", outputFile))
c.File(outputPath)
c.Header("Content-Type", contentType)
}

View File

@@ -1,565 +0,0 @@
package handlers
// export_data.go — v0.34.0 CS0
//
// Data export endpoints: user data export (GDPR "download my data")
// and team data export (admin). Streams .switchboard zip archives
// directly to the HTTP response.
import (
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/export"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
)
// DataExportHandler serves data export endpoints.
type DataExportHandler struct {
stores store.Stores
objStore storage.ObjectStore
}
// NewDataExportHandler creates a new handler for data export operations.
func NewDataExportHandler(s store.Stores, obj storage.ObjectStore) *DataExportHandler {
return &DataExportHandler{stores: s, objStore: obj}
}
// ExportMyData streams the requesting user's data as a .switchboard zip.
// GET /api/v1/export/me
func (h *DataExportHandler) ExportMyData(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
// ── Fetch user ──
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// ── Fetch all user-scoped entities ──
channels, err := h.stores.Export.UserChannels(ctx, userID)
if err != nil {
slog.Error("export: fetch channels", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channels"})
return
}
channelIDs := make([]string, len(channels))
for i, ch := range channels {
channelIDs[i] = ch.ID
}
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch messages", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export messages"})
return
}
participants, err := h.stores.Export.UserChannelParticipants(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch participants", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export participants"})
return
}
channelModels, err := h.stores.Export.UserChannelModels(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch channel models", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export channel models"})
return
}
cursors, err := h.stores.Export.UserChannelCursors(ctx, userID, channelIDs)
if err != nil {
slog.Error("export: fetch cursors", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export cursors"})
return
}
notes, err := h.stores.Export.UserNotes(ctx, userID)
if err != nil {
slog.Error("export: fetch notes", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notes"})
return
}
noteIDs := make([]string, len(notes))
for i, n := range notes {
noteIDs[i] = n.ID
}
noteLinks, err := h.stores.Export.UserNoteLinks(ctx, noteIDs)
if err != nil {
slog.Error("export: fetch note links", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export note links"})
return
}
memories, err := h.stores.Export.UserMemories(ctx, userID)
if err != nil {
slog.Error("export: fetch memories", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export memories"})
return
}
projects, err := h.stores.Export.UserProjects(ctx, userID)
if err != nil {
slog.Error("export: fetch projects", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export projects"})
return
}
projectIDs := make([]string, len(projects))
for i, p := range projects {
projectIDs[i] = p.ID
}
projectChannels, err := h.stores.Export.UserProjectChannels(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project channels", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project channels"})
return
}
projectKBs, err := h.stores.Export.UserProjectKBs(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project KBs"})
return
}
projectNotes, err := h.stores.Export.UserProjectNotes(ctx, projectIDs)
if err != nil {
slog.Error("export: fetch project notes", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export project notes"})
return
}
workspaces, err := h.stores.Export.UserWorkspaces(ctx, userID)
if err != nil {
slog.Error("export: fetch workspaces", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspaces"})
return
}
workspaceIDs := make([]string, len(workspaces))
for i, w := range workspaces {
workspaceIDs[i] = w.ID
}
workspaceFiles, err := h.stores.Export.UserWorkspaceFiles(ctx, workspaceIDs)
if err != nil {
slog.Error("export: fetch workspace files", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workspace files"})
return
}
files, err := h.stores.Export.UserFiles(ctx, userID)
if err != nil {
slog.Error("export: fetch files", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export files"})
return
}
folders, err := h.stores.Export.UserFolders(ctx, userID)
if err != nil {
slog.Error("export: fetch folders", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export folders"})
return
}
userModelSettings, err := h.stores.Export.UserModelSettings(ctx, userID)
if err != nil {
slog.Error("export: fetch user model settings", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export user settings"})
return
}
notifPrefs, err := h.stores.Export.UserNotifPrefs(ctx, userID)
if err != nil {
slog.Error("export: fetch notification prefs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export notification prefs"})
return
}
usageEntries, err := h.stores.Export.UserUsageEntries(ctx, userID)
if err != nil {
slog.Error("export: fetch usage entries", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export usage entries"})
return
}
personaGroups, err := h.stores.Export.UserPersonaGroups(ctx, userID)
if err != nil {
slog.Error("export: fetch persona groups", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona groups"})
return
}
pgIDs := make([]string, len(personaGroups))
for i, pg := range personaGroups {
pgIDs[i] = pg.ID
}
personaGroupMembers, err := h.stores.Export.UserPersonaGroupMembers(ctx, pgIDs)
if err != nil {
slog.Error("export: fetch persona group members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona group members"})
return
}
// ── Build manifest ──
sanitizedUser := export.SanitizeUser(user)
sanitizedChannels := export.SanitizeChannels(channels)
sanitizedMessages := export.SanitizeMessages(messages)
sanitizedChannelModels := export.SanitizeChannelModels(channelModels)
sanitizedUserModelSettings := export.SanitizeUserModelSettings(userModelSettings)
sanitizedUsageEntries := export.SanitizeUsageEntries(usageEntries)
sanitizedWorkspaces := export.SanitizeWorkspaces(workspaces)
counts := map[string]int{
"users": 1,
"channels": len(channels),
"messages": len(messages),
"channel_participants": len(participants),
"channel_models": len(channelModels),
"channel_cursors": len(cursors),
"notes": len(notes),
"note_links": len(noteLinks),
"memories": len(memories),
"projects": len(projects),
"project_channels": len(projectChannels),
"project_knowledge_bases": len(projectKBs),
"project_notes": len(projectNotes),
"workspaces": len(workspaces),
"workspace_files": len(workspaceFiles),
"files": len(files),
"folders": len(folders),
"user_settings": len(userModelSettings),
"notification_preferences": len(notifPrefs),
"usage_entries": len(usageEntries),
"persona_groups": len(personaGroups),
"persona_group_members": len(personaGroupMembers),
}
manifest := &export.Manifest{
Version: "0.34.0",
FormatVersion: export.FormatVersion,
ExportType: "user",
CreatedAt: time.Now().UTC(),
ExportedBy: userID,
EntityCounts: counts,
Scope: &export.ManifestScope{UserID: userID},
}
// ── Stream zip to response ──
filename := fmt.Sprintf("switchboard-export-%s%s", userID[:8], export.ExportExtension)
c.Header("Content-Type", export.ExportContentType)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
aw := export.NewArchiveWriter(c.Writer)
defer aw.Close()
if err := aw.WriteManifest(manifest); err != nil {
slog.Error("export: write manifest", "error", err)
return
}
// Write entity JSON files
writeEntity := func(name string, data interface{}) {
if _, err := aw.WriteEntityJSON(name, data); err != nil {
slog.Error("export: write entity", "name", name, "error", err)
}
}
writeEntity("users", []interface{}{sanitizedUser})
writeEntity("channels", sanitizedChannels)
writeEntity("messages", sanitizedMessages)
writeEntity("channel_participants", participants)
writeEntity("channel_models", sanitizedChannelModels)
writeEntity("channel_cursors", cursors)
writeEntity("notes", notes)
writeEntity("note_links", noteLinks)
writeEntity("memories", memories)
writeEntity("projects", projects)
writeEntity("project_channels", projectChannels)
writeEntity("project_knowledge_bases", projectKBs)
writeEntity("project_notes", projectNotes)
writeEntity("workspaces", sanitizedWorkspaces)
writeEntity("workspace_files", workspaceFiles)
writeEntity("folders", folders)
writeEntity("user_settings", sanitizedUserModelSettings)
writeEntity("notification_preferences", notifPrefs)
writeEntity("usage_entries", sanitizedUsageEntries)
writeEntity("persona_groups", personaGroups)
writeEntity("persona_group_members", personaGroupMembers)
// Write file metadata (without storage_key — already excluded by json:"-" tag)
writeEntity("files", files)
// ── Stream file blobs ──
var warnings []string
if h.objStore != nil {
fileCount := 0
for _, f := range files {
if fileCount >= export.MaxExportFiles {
warnings = append(warnings, "file blob limit reached, some files skipped")
break
}
if f.SizeBytes > export.MaxExportFileSize {
warnings = append(warnings, fmt.Sprintf("file %s too large (%d bytes), skipped", f.Filename, f.SizeBytes))
continue
}
rc, _, _, err := h.objStore.Get(ctx, f.StorageKey)
if err != nil {
warnings = append(warnings, fmt.Sprintf("file %s not found in storage, skipped", f.Filename))
continue
}
zipPath := f.ID + "/" + f.Filename
if _, err := aw.WriteFile(zipPath, rc); err != nil {
rc.Close()
warnings = append(warnings, fmt.Sprintf("file %s write error: %v, skipped", f.Filename, err))
continue
}
rc.Close()
fileCount++
}
}
if len(warnings) > 0 {
writeEntity("export_warnings", warnings)
}
// Audit log
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.export",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"entity_counts": counts},
})
}
// ExportTeam streams a team's data as a .switchboard zip.
// GET /api/v1/admin/teams/:id/export
func (h *DataExportHandler) ExportTeam(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
teamID := c.Param("id")
// Fetch team to verify it exists
team, err := h.stores.Teams.GetByID(ctx, teamID)
if err != nil || team == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Fetch all team-scoped entities
channels, err := h.stores.Export.TeamChannels(ctx, teamID)
if err != nil {
slog.Error("export: fetch team channels", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team channels"})
return
}
channelIDs := make([]string, len(channels))
for i, ch := range channels {
channelIDs[i] = ch.ID
}
messages, err := h.stores.Export.UserMessages(ctx, channelIDs)
if err != nil {
slog.Error("export: fetch team messages", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team messages"})
return
}
members, err := h.stores.Export.TeamMembers(ctx, teamID)
if err != nil {
slog.Error("export: fetch team members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team members"})
return
}
personas, err := h.stores.Export.TeamPersonas(ctx, teamID)
if err != nil {
slog.Error("export: fetch team personas", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team personas"})
return
}
personaIDs := make([]string, len(personas))
for i, p := range personas {
personaIDs[i] = p.ID
}
personaKBs, err := h.stores.Export.TeamPersonaKBs(ctx, personaIDs)
if err != nil {
slog.Error("export: fetch persona KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export persona KBs"})
return
}
kbs, err := h.stores.Export.TeamKnowledgeBases(ctx, teamID)
if err != nil {
slog.Error("export: fetch team KBs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team KBs"})
return
}
kbIDs := make([]string, len(kbs))
for i, kb := range kbs {
kbIDs[i] = kb.ID
}
kbDocs, err := h.stores.Export.TeamKBDocuments(ctx, kbIDs)
if err != nil {
slog.Error("export: fetch KB docs", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export KB documents"})
return
}
workflows, err := h.stores.Export.TeamWorkflows(ctx, teamID)
if err != nil {
slog.Error("export: fetch workflows", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflows"})
return
}
workflowIDs := make([]string, len(workflows))
for i, w := range workflows {
workflowIDs[i] = w.ID
}
workflowVersions, err := h.stores.Export.TeamWorkflowVersions(ctx, workflowIDs)
if err != nil {
slog.Error("export: fetch workflow versions", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow versions"})
return
}
workflowStages, err := h.stores.Export.TeamWorkflowStages(ctx, workflowIDs)
if err != nil {
slog.Error("export: fetch workflow stages", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export workflow stages"})
return
}
groups, err := h.stores.Export.TeamGroups(ctx, teamID)
if err != nil {
slog.Error("export: fetch groups", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export groups"})
return
}
groupIDs := make([]string, len(groups))
for i, g := range groups {
groupIDs[i] = g.ID
}
groupMembers, err := h.stores.Export.TeamGroupMembers(ctx, groupIDs)
if err != nil {
slog.Error("export: fetch group members", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export group members"})
return
}
resourceGrants, err := h.stores.Export.TeamResourceGrants(ctx, teamID)
if err != nil {
slog.Error("export: fetch resource grants", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export resource grants"})
return
}
projects, err := h.stores.Export.TeamProjects(ctx, teamID)
if err != nil {
slog.Error("export: fetch team projects", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export team projects"})
return
}
// Sanitize
sanitizedChannels := export.SanitizeChannels(channels)
sanitizedMessages := export.SanitizeMessages(messages)
sanitizedPersonas := export.SanitizePersonas(personas)
sanitizedWorkflows := export.SanitizeWorkflows(workflows)
counts := map[string]int{
"team": 1,
"channels": len(channels),
"messages": len(messages),
"members": len(members),
"personas": len(personas),
"persona_kbs": len(personaKBs),
"knowledge_bases": len(kbs),
"kb_documents": len(kbDocs),
"workflows": len(workflows),
"workflow_versions": len(workflowVersions),
"workflow_stages": len(workflowStages),
"groups": len(groups),
"group_members": len(groupMembers),
"resource_grants": len(resourceGrants),
"projects": len(projects),
}
manifest := &export.Manifest{
Version: "0.34.0",
FormatVersion: export.FormatVersion,
ExportType: "team",
CreatedAt: time.Now().UTC(),
ExportedBy: userID,
EntityCounts: counts,
Scope: &export.ManifestScope{TeamID: teamID},
}
filename := fmt.Sprintf("switchboard-team-%s%s", teamID[:8], export.ExportExtension)
c.Header("Content-Type", export.ExportContentType)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
aw := export.NewArchiveWriter(c.Writer)
defer aw.Close()
if err := aw.WriteManifest(manifest); err != nil {
slog.Error("export: write manifest", "error", err)
return
}
writeEntity := func(name string, data interface{}) {
if _, err := aw.WriteEntityJSON(name, data); err != nil {
slog.Error("export: write entity", "name", name, "error", err)
}
}
writeEntity("team", []interface{}{team})
writeEntity("channels", sanitizedChannels)
writeEntity("messages", sanitizedMessages)
writeEntity("members", members)
writeEntity("personas", sanitizedPersonas)
writeEntity("persona_knowledge_bases", personaKBs)
writeEntity("knowledge_bases", kbs)
writeEntity("kb_documents", kbDocs)
writeEntity("workflows", sanitizedWorkflows)
writeEntity("workflow_versions", workflowVersions)
writeEntity("workflow_stages", workflowStages)
writeEntity("groups", groups)
writeEntity("group_members", groupMembers)
writeEntity("resource_grants", resourceGrants)
writeEntity("projects", projects)
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "team.export",
ResourceType: "team",
ResourceID: teamID,
Metadata: models.JSONMap{"entity_counts": counts},
})
}

View File

@@ -1,166 +0,0 @@
package handlers
import (
"encoding/json"
"testing"
"go.starlark.net/starlark"
)
// ── jsonToStarlark ────────────────────────────────────────────────────────────
func TestJSONToStarlark_String(t *testing.T) {
v := jsonToStarlark("hello")
s, ok := v.(starlark.String)
if !ok || string(s) != "hello" {
t.Errorf("expected starlark.String(hello), got %v (%T)", v, v)
}
}
func TestJSONToStarlark_Int(t *testing.T) {
v := jsonToStarlark(float64(42))
i, ok := v.(starlark.Int)
if !ok {
t.Fatalf("expected starlark.Int, got %T", v)
}
n, _ := i.Int64()
if n != 42 {
t.Errorf("expected 42, got %d", n)
}
}
func TestJSONToStarlark_Float(t *testing.T) {
v := jsonToStarlark(3.14)
if _, ok := v.(starlark.Float); !ok {
t.Errorf("expected starlark.Float, got %T", v)
}
}
func TestJSONToStarlark_Bool(t *testing.T) {
if jsonToStarlark(true) != starlark.True {
t.Error("expected starlark.True")
}
if jsonToStarlark(false) != starlark.False {
t.Error("expected starlark.False")
}
}
func TestJSONToStarlark_Nil(t *testing.T) {
if jsonToStarlark(nil) != starlark.None {
t.Error("expected starlark.None for nil")
}
}
func TestJSONToStarlark_Dict(t *testing.T) {
v := jsonToStarlark(map[string]interface{}{"key": "val"})
d, ok := v.(*starlark.Dict)
if !ok {
t.Fatalf("expected *starlark.Dict, got %T", v)
}
got, found, _ := d.Get(starlark.String("key"))
if !found || string(got.(starlark.String)) != "val" {
t.Errorf("expected key=val in dict, got %v", got)
}
}
func TestJSONToStarlark_List(t *testing.T) {
v := jsonToStarlark([]interface{}{"a", "b"})
l, ok := v.(*starlark.List)
if !ok {
t.Fatalf("expected *starlark.List, got %T", v)
}
if l.Len() != 2 {
t.Errorf("expected 2 elements, got %d", l.Len())
}
}
// ── starlarkValueToGo ─────────────────────────────────────────────────────────
func TestStarlarkValueToGo_String(t *testing.T) {
v := starlarkValueToGo(starlark.String("hi"))
if v != "hi" {
t.Errorf("expected 'hi', got %v", v)
}
}
func TestStarlarkValueToGo_Int(t *testing.T) {
v := starlarkValueToGo(starlark.MakeInt(7))
if v != int64(7) {
t.Errorf("expected int64(7), got %v (%T)", v, v)
}
}
func TestStarlarkValueToGo_Bool(t *testing.T) {
if starlarkValueToGo(starlark.True) != true {
t.Error("expected true")
}
}
func TestStarlarkValueToGo_None(t *testing.T) {
if starlarkValueToGo(starlark.None) != nil {
t.Error("expected nil for starlark.None")
}
}
func TestStarlarkValueToGo_Dict(t *testing.T) {
d := starlark.NewDict(1)
_ = d.SetKey(starlark.String("x"), starlark.MakeInt(1))
v := starlarkValueToGo(d)
m, ok := v.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", v)
}
if m["x"] != int64(1) {
t.Errorf("expected x=1, got %v", m["x"])
}
}
func TestStarlarkValueToGo_List(t *testing.T) {
l := starlark.NewList([]starlark.Value{starlark.String("a"), starlark.String("b")})
v := starlarkValueToGo(l)
arr, ok := v.([]interface{})
if !ok {
t.Fatalf("expected []interface{}, got %T", v)
}
if len(arr) != 2 || arr[0] != "a" {
t.Errorf("unexpected list: %v", arr)
}
}
// ── RoundTrip: JSON → Starlark → Go → JSON ───────────────────────────────────
func TestRoundTrip_JSONToStarlarkToJSON(t *testing.T) {
// Simulate what executeExtensionTool does: parse args, pass as Starlark, convert result back.
argsJSON := `{"query": "hello", "limit": 10, "active": true}`
var raw map[string]interface{}
if err := json.Unmarshal([]byte(argsJSON), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
sv := jsonToStarlark(raw)
got := starlarkValueToGo(sv)
out, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Verify key fields present
var result map[string]interface{}
if err := json.Unmarshal(out, &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result["query"] != "hello" {
t.Errorf("expected query=hello, got %v", result["query"])
}
if result["active"] != true {
t.Errorf("expected active=true, got %v", result["active"])
}
}
// ── BuildExtToolMap ───────────────────────────────────────────────────────────
func TestBuildExtToolMap_Empty(t *testing.T) {
// nil Packages store → returns nil (no panic)
result := BuildExtToolMap(nil, storesWithExtData(newMemExtDataStore()), "user1")
if result != nil && len(result) != 0 {
t.Errorf("expected empty map for stores without Packages, got %v", result)
}
}

View File

@@ -1,993 +0,0 @@
package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/extraction"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
"switchboard-core/workspace"
)
// ── Default Limits ─────────────────────────
// Overridable via global_settings keys.
const (
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
defaultMaxFilesPerMsg = 5 // future: multi-file per message
defaultOrphanMaxAge = 24 * time.Hour
)
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
var allowedMIMETypes = map[string]bool{
// Images
"image/jpeg": true, "image/png": true, "image/gif": true,
"image/webp": true, "image/svg+xml": true,
// Documents
"application/pdf": true,
"text/plain": true, "text/markdown": true, "text/csv": true,
// Microsoft Office
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
"application/msword": true, "application/vnd.ms-excel": true,
// OpenDocument
"application/vnd.oasis.opendocument.text": true,
"application/vnd.oasis.opendocument.spreadsheet": true,
"application/vnd.oasis.opendocument.presentation": true,
// Other
"application/rtf": true,
}
// ── Handler ────────────────────────────────
type FileHandler struct {
stores store.Stores
objStore storage.ObjectStore
wfs *workspace.FS // nil if workspace FS not configured
extQueue *extraction.Queue // nil if extraction disabled
}
func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler {
return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue}
}
// SetWorkspaceFS attaches the workspace filesystem for project file operations.
func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) {
h.wfs = wfs
}
// ── Upload ─────────────────────────────────
// POST /api/v1/channels/:id/files
// Multipart form: file field "file", returns file metadata.
func (h *FileHandler) Upload(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
channelID := c.Param("id")
// Verify channel ownership
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
// Parse multipart
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
// Size check
if header.Size > defaultMaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
})
return
}
// MIME detection: read first 512 bytes for sniffing, then reset
buf := make([]byte, 512)
n, _ := file.Read(buf)
detectedType := http.DetectContentType(buf[:n])
// Reset reader to start
if _, err := file.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
return
}
// Normalize MIME type (strip params like charset)
contentType := detectedType
if idx := strings.Index(contentType, ";"); idx > 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
// For types that DetectContentType can't distinguish (returns application/octet-stream),
// fall back to extension-based detection
if contentType == "application/octet-stream" {
ext := strings.ToLower(filepath.Ext(header.Filename))
if mapped, ok := extToMIME[ext]; ok {
contentType = mapped
}
}
// Allowlist check
if !allowedMIMETypes[contentType] {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file type %q not allowed", contentType),
})
return
}
// Build storage key: files are stored under files/{channel_id}/{file_id}_{filename}
// We generate the ID first via a temp UUID, then use it in the key.
att := &models.File{
ChannelID: channelID,
UserID: userID,
Origin: models.FileOriginUserUpload,
Filename: sanitizeFilename(header.Filename),
ContentType: contentType,
SizeBytes: header.Size,
DisplayHint: displayHintFor(contentType),
Metadata: models.JSONMap{
"extraction_status": "pending",
},
}
// Create PG row first to get the UUID
// storage_key is set after we have the ID
att.StorageKey = "placeholder"
if err := h.stores.Files.Create(c.Request.Context(), att); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"})
return
}
// Now build the real storage key and update
att.StorageKey = fmt.Sprintf("files/%s/%s_%s", channelID, att.ID, att.Filename)
// Write to object store
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
// Rollback PG row on storage failure
h.stores.Files.Delete(c.Request.Context(), att.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
// Update storage_key
h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey)
// For images, mark extraction as not needed (complete immediately)
if strings.HasPrefix(contentType, "image/") {
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
// For text/plain, extract inline (trivial — just read the file)
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
if _, err := file.Seek(0, io.SeekStart); err == nil {
if textBytes, err := io.ReadAll(file); err == nil {
text := string(textBytes)
h.stores.Files.SetExtractedText(c.Request.Context(), att.ID, text)
h.stores.Files.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
"extraction_status": "complete",
})
att.Metadata["extraction_status"] = "complete"
}
}
}
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
if extraction.IsExtractable(contentType) && h.extQueue != nil {
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
// Non-fatal: file is uploaded, just won't have extracted text
}
}
c.JSON(http.StatusCreated, att)
}
// ── Download ───────────────────────────────
// GET /api/v1/files/:id/download
// Streams file content with auth check via channel membership.
func (h *FileHandler) Download(c *gin.Context) {
if h.objStore == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
// Channel-scoped access check
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer reader.Close()
c.Header("Content-Type", att.ContentType)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.Status(http.StatusOK)
io.Copy(c.Writer, reader)
}
// ── Get Metadata ───────────────────────────
// GET /api/v1/files/:id
func (h *FileHandler) GetMetadata(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
c.JSON(http.StatusOK, att)
}
// ── List Channel Files ───────────────
// GET /api/v1/channels/:id/files
func (h *FileHandler) ListByChannel(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !h.verifyChannelAccess(c, channelID, userID) {
return
}
origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system)
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by Message ───────────────────────
// GET /api/v1/messages/:id/files
// Returns files attached to a specific message (inline artifacts, tool output).
func (h *FileHandler) ListByMessage(c *gin.Context) {
messageID := c.Param("id")
files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ── List by User (File Manager) ───────────
// GET /api/v1/files?page=1&per_page=50
// Paginated list of all files owned by the authenticated user.
func (h *FileHandler) ListByUser(c *gin.Context) {
userID := getUserID(c)
page, perPage, _ := parsePagination(c)
files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.File{}
}
c.JSON(http.StatusOK, gin.H{
"files": files,
"total": total,
"page": page,
"per_page": perPage,
})
}
// ── Delete ─────────────────────────────────
// DELETE /api/v1/files/:id
func (h *FileHandler) Delete(c *gin.Context) {
userID := getUserID(c)
attID := c.Param("id")
att, err := h.stores.Files.GetByID(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
return
}
// Delete from PG (returns the row for storage cleanup)
deleted, err := h.stores.Files.Delete(c.Request.Context(), attID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
// Clean up storage (async-safe: fire and forget with background context)
if h.objStore != nil && deleted != nil {
storageKey := deleted.StorageKey
go func() {
ctx := context.Background()
if err := h.objStore.Delete(ctx, storageKey); err != nil {
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
}
// Also clean up thumbnail if it exists
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
}()
}
c.JSON(http.StatusOK, gin.H{"message": "file deleted"})
}
// ── Admin: Orphan Cleanup ──────────────────
// POST /admin/storage/cleanup
func (h *FileHandler) CleanupOrphans(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
return
}
deleted := 0
var freedBytes int64
for _, att := range orphans {
if _, err := h.stores.Files.Delete(c.Request.Context(), att.ID); err != nil {
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
continue
}
if h.objStore != nil {
h.objStore.Delete(c.Request.Context(), att.StorageKey)
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
}
deleted++
freedBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"deleted": deleted,
"freed_bytes": freedBytes,
"scanned": len(orphans),
})
}
// ── Admin: Orphan Count ────────────────────
// GET /admin/storage/orphans (for the admin panel card)
func (h *FileHandler) OrphanCount(c *gin.Context) {
orphans, err := h.stores.Files.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
return
}
var totalBytes int64
for _, att := range orphans {
totalBytes += att.SizeBytes
}
c.JSON(http.StatusOK, gin.H{
"count": len(orphans),
"reclaimable_bytes": totalBytes,
})
}
// ── Admin: Extraction Queue Status ─────────
// GET /admin/storage/extraction
func (h *FileHandler) ExtractionStatus(c *gin.Context) {
if h.extQueue == nil {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"items": []interface{}{},
})
return
}
items, err := h.extQueue.ListAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
return
}
if items == nil {
items = []extraction.QueueItem{}
}
// Count by status
counts := map[string]int{}
for _, item := range items {
counts[item.Status]++
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"total": len(items),
"counts": counts,
"items": items,
})
}
// ── Channel Delete Hook ────────────────────
// Called by ChannelHandler.DeleteChannel to clean up storage.
func (h *FileHandler) CleanupChannelStorage(channelID string) {
if h.objStore == nil {
return
}
// CASCADE already deleted PG rows. Clean up filesystem.
prefix := fmt.Sprintf("files/%s", channelID)
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
}
}
// ── Helpers ────────────────────────────────
// verifyChannelAccess checks that the requesting user owns the channel.
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
func (h *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return false
}
if !owns {
// Check if user is admin (admins can access any channel)
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return false
}
}
return true
}
// sanitizeFilename cleans a filename for safe storage.
func sanitizeFilename(name string) string {
// Take only the base name (strip path separators)
name = filepath.Base(name)
// Replace problematic characters
replacer := strings.NewReplacer(
"/", "_", "\\", "_", "..", "_", "\x00", "",
)
name = replacer.Replace(name)
if name == "" || name == "." {
name = "unnamed"
}
// Truncate to 200 chars (leave room for UUID prefix in storage key)
if len(name) > 200 {
ext := filepath.Ext(name)
name = name[:200-len(ext)] + ext
}
return name
}
// ── Project Files (v0.22.4, reworked v0.37.17) ─────────────
// Project files route through the Workspace FS subsystem.
// A workspace is auto-created on first file upload (lazy binding).
// verifyProjectAccess checks that the user can access the project.
// Returns true if access is granted (admins bypass).
func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool {
if c.GetString("role") == "admin" {
return true
}
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil || !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"})
return false
}
return true
}
// ensureProjectWorkspace returns the project's workspace, creating one if needed.
func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) {
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("project not found: %w", err)
}
// Already has a workspace — load and return it.
if proj.WorkspaceID != nil && *proj.WorkspaceID != "" {
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err)
}
return ws, nil
}
// Create workspace for this project.
ws := &models.Workspace{
OwnerType: models.WorkspaceOwnerProject,
OwnerID: projectID,
Name: proj.Name + " Files",
Status: models.WorkspaceStatusActive,
}
ws.ID = store.NewID()
ws.RootPath = "workspaces/" + ws.ID
if err := h.stores.Workspaces.Create(ctx, ws); err != nil {
// Race guard: another request may have created it. Re-read the project.
proj2, err2 := h.stores.Projects.GetByID(ctx, projectID)
if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" {
return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID)
}
return nil, fmt.Errorf("failed to create workspace: %w", err)
}
// Create directory on disk.
if err := h.wfs.CreateDir(ws); err != nil {
log.Printf("warning: workspace mkdir for project %s: %v", projectID, err)
}
// Bind workspace to project.
h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID})
return ws, nil
}
// UploadToProject handles project-scoped file uploads via workspace FS.
// POST /api/v1/projects/:id/files
func (h *FileHandler) UploadToProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
log.Printf("error: ensure workspace for project %s: %v", projectID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
if header.Size > int64(defaultMaxFileSize) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"})
return
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID)
quota := workspace.DefaultMaxBytes
if ws.MaxBytes != nil {
quota = *ws.MaxBytes
}
if stats != nil && stats.TotalBytes+header.Size > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
return
}
// Detect content type
buf := make([]byte, 512)
n, _ := file.Read(buf)
contentType := http.DetectContentType(buf[:n])
file.Seek(0, io.SeekStart)
ext := strings.ToLower(filepath.Ext(header.Filename))
if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" {
contentType = better
}
filename := sanitizeFilename(header.Filename)
// Determine upload path (support optional path prefix via query param)
uploadPath := filename
if prefix := c.Query("path"); prefix != "" {
uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename
}
if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil {
log.Printf("error: project file write: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
c.JSON(http.StatusCreated, gin.H{
"path": uploadPath,
"filename": filename,
"content_type": contentType,
"size_bytes": header.Size,
})
}
// ListByProject returns workspace files for a project.
// GET /api/v1/projects/:id/files
func (h *FileHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
// Load project to get workspace_id
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// No workspace yet → empty list
if proj.WorkspaceID == nil || *proj.WorkspaceID == "" {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
dirPath := c.DefaultQuery("path", "")
recursive := c.DefaultQuery("recursive", "true") == "true"
files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive)
if err != nil {
log.Printf("error: list project files: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
}
// DownloadProjectFile streams a single file from the project workspace.
// GET /api/v1/projects/:id/files/download?path=...
func (h *FileHandler) DownloadProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
rc, size, err := h.wfs.ReadFile(ctx, ws, filePath)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type
f, _ := h.wfs.Stat(ctx, ws, filePath)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
filename := filepath.Base(filePath)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
// DeleteProjectFile deletes a file or directory from the project workspace.
// DELETE /api/v1/projects/:id/files?path=...
func (h *FileHandler) DeleteProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// MkdirProject creates a directory in the project workspace.
// POST /api/v1/projects/:id/files/mkdir
func (h *FileHandler) MkdirProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
// Accept path from query param or JSON body
dirPath := c.Query("path")
if dirPath == "" {
var body struct {
Path string `json:"path"`
}
if c.ShouldBindJSON(&body) == nil && body.Path != "" {
dirPath = body.Path
}
}
if dirPath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath})
}
// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace.
// POST /api/v1/projects/:id/archive/upload
func (h *FileHandler) UploadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (zip extraction needs seekable file)
tmp, err := os.CreateTemp("", "project-archive-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(ctx, ws, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "files_extracted": count})
}
// DownloadProjectArchive creates and streams a zip/tar.gz of all project files.
// GET /api/v1/projects/:id/archive/download?format=zip
func (h *FileHandler) DownloadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(ctx, ws, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
}
filename := fmt.Sprintf("%s.%s", proj.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
}
// extToMIME maps file extensions to MIME types for cases where
// http.DetectContentType returns application/octet-stream.
var extToMIME = map[string]string{
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".doc": "application/msword",
".xls": "application/vnd.ms-excel",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odp": "application/vnd.oasis.opendocument.presentation",
".rtf": "application/rtf",
".md": "text/markdown",
".csv": "text/csv",
".svg": "image/svg+xml",
}
// displayHintFor returns the appropriate display hint for a content type.
func displayHintFor(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "video/"):
return models.FileHintInline
case strings.HasPrefix(contentType, "audio/"):
return models.FileHintInline
case contentType == "application/pdf":
return models.FileHintThumbnail
case strings.HasPrefix(contentType, "text/"):
return models.FileHintInline
case contentType == "application/json":
return models.FileHintInline
default:
return models.FileHintDownload
}
}

View File

@@ -1,127 +0,0 @@
package handlers
// folders.go — Chat folder CRUD (v0.23.1)
//
// v0.29.0: Raw SQL replaced with FolderStore methods.
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
type FolderHandler struct {
stores store.Stores
}
func NewFolderHandler(s store.Stores) *FolderHandler {
return &FolderHandler{stores: s}
}
func (h *FolderHandler) List(c *gin.Context) {
userID := getUserID(c)
folders, err := h.stores.Folders.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
c.JSON(http.StatusOK, gin.H{"data": folders})
}
func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
ParentID *string `json:"parent_id"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
f := &models.Folder{
UserID: userID,
Name: req.Name,
ParentID: req.ParentID,
SortOrder: req.SortOrder,
}
if err := h.stores.Folders.Create(c.Request.Context(), f); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"folder": f})
}
func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Read raw body to detect which fields were explicitly sent
data, err := c.GetRawData()
if err != nil || len(data) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
ParentID *string `json:"parent_id"`
}
if err := json.Unmarshal(data, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
}
// Only update parent_id if it was explicitly present in the JSON
var parentID **string
var raw map[string]json.RawMessage
_ = json.Unmarshal(data, &raw)
if _, ok := raw["parent_id"]; ok {
parentID = &req.ParentID
}
n, err := h.stores.Folders.Update(c.Request.Context(), folderID, userID, req.Name, req.SortOrder, parentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *FolderHandler) Delete(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Unassign chats before deleting folder
_ = h.stores.Folders.UnassignChannels(c.Request.Context(), folderID, userID)
n, err := h.stores.Folders.Delete(c.Request.Context(), folderID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -1,130 +0,0 @@
package handlers
// gdpr.go — v0.34.0 CS2
//
// GDPR delete endpoint: DELETE /api/v1/me
// Allows a user to delete their own account and all associated data.
import (
"crypto/sha256"
"fmt"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"switchboard-core/models"
"switchboard-core/store"
)
// GDPRHandler serves account deletion endpoints.
type GDPRHandler struct {
stores store.Stores
}
// NewGDPRHandler creates a new handler for GDPR operations.
func NewGDPRHandler(s store.Stores) *GDPRHandler {
return &GDPRHandler{stores: s}
}
// deleteAccountRequest is the body for DELETE /api/v1/me.
type deleteAccountRequest struct {
Confirm string `json:"confirm"`
Password string `json:"password"`
}
// DeleteMyAccount permanently deletes the requesting user's account.
// DELETE /api/v1/me
func (h *GDPRHandler) DeleteMyAccount(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
var req deleteAccountRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
// Require explicit confirmation
if req.Confirm != "DELETE" {
c.JSON(http.StatusBadRequest, gin.H{"error": "confirm field must be \"DELETE\""})
return
}
// Fetch user
user, err := h.stores.Users.GetByID(ctx, userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return
}
// Prevent deleting last admin
if user.Role == "admin" {
adminCount, err := h.stores.Export.CountActiveAdmins(ctx)
if err != nil {
slog.Error("gdpr: count admins", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin count"})
return
}
if adminCount <= 1 {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete the last admin account"})
return
}
}
// Generate anonymized hash from user ID
hash := sha256.Sum256([]byte(userID))
anonHash := fmt.Sprintf("%x", hash[:6]) // 12 hex chars
// Step 1: Soft-delete/hard-delete user data
counts, err := h.stores.Export.SoftDeleteUserData(ctx, userID)
if err != nil {
slog.Error("gdpr: delete user data", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user data"})
return
}
// Step 2: Revoke tokens
if err := h.stores.Export.DeleteUserTokens(ctx, userID); err != nil {
slog.Error("gdpr: delete tokens", "error", err, "user_id", userID)
// Continue — tokens will expire naturally
}
// Step 3: Delete personal provider configs
if deleted, err := h.stores.Providers.DeletePersonalByOwner(ctx, userID); err != nil {
slog.Error("gdpr: delete provider configs", "error", err)
} else {
counts["provider_configs"] = deleted
}
// Step 4: Anonymize user record
if err := h.stores.Export.AnonymizeUser(ctx, userID, anonHash); err != nil {
slog.Error("gdpr: anonymize user", "error", err, "user_id", userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to anonymize user"})
return
}
// Audit log (before the user record is fully anonymized)
anonUsername := "deleted-user-" + anonHash
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.gdpr_delete",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"anonymized_as": anonUsername, "deleted_counts": counts},
})
slog.Info("gdpr: account deleted", "user_id", userID, "anonymized_as", anonUsername)
c.JSON(http.StatusOK, gin.H{
"message": "account deleted",
"anonymized_as": anonUsername,
})
}

View File

@@ -1,436 +0,0 @@
package handlers
import (
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"strconv"
"golang.org/x/crypto/ssh"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workspace"
"github.com/gin-gonic/gin"
)
// =========================================
// GIT HANDLER — Git operations on workspaces
// =========================================
type GitHandler struct {
stores store.Stores
gitOps *workspace.GitOps
}
func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler {
return &GitHandler{stores: s, gitOps: gitOps}
}
// ── Clone ────────────────────────────────────
func (h *GitHandler) Clone(c *gin.Context) {
wsID := c.Param("id")
var req struct {
URL string `json:"url" binding:"required"`
Branch string `json:"branch"`
CredentialID string `json:"credential_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "cloned"})
}
// ── Pull ─────────────────────────────────────
func (h *GitHandler) Pull(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
if err := h.gitOps.Pull(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "pulled"})
}
// ── Push ─────────────────────────────────────
func (h *GitHandler) Push(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
if err := h.gitOps.Push(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "pushed"})
}
// ── Status ───────────────────────────────────
func (h *GitHandler) Status(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
status, err := h.gitOps.Status(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, status)
}
// ── Diff ─────────────────────────────────────
func (h *GitHandler) Diff(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
path := c.Query("path")
diff, err := h.gitOps.Diff(c.Request.Context(), w, path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"diff": diff})
}
// ── Commit ───────────────────────────────────
func (h *GitHandler) Commit(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
var req struct {
Message string `json:"message" binding:"required"`
Paths []string `json:"paths"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "committed"})
}
// ── Log ──────────────────────────────────────
func (h *GitHandler) Log(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
n := 20
if v := c.Query("n"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
if n > 100 {
n = 100
}
}
}
entries, err := h.gitOps.Log(c.Request.Context(), w, n)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if entries == nil {
entries = []models.GitLogEntry{}
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// ── Branches ─────────────────────────────────
func (h *GitHandler) Branches(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
branches, current, err := h.gitOps.BranchList(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"current": current,
"branches": branches,
})
}
// ── Checkout ─────────────────────────────────
func (h *GitHandler) Checkout(c *gin.Context) {
w, err := h.loadWorkspace(c)
if err != nil {
return
}
var req struct {
Branch string `json:"branch" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch})
}
// loadWorkspace loads the workspace and validates git is configured.
func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) {
wsID := c.Param("id")
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return nil, err
}
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
err := fmt.Errorf("workspace has no git remote configured")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, err
}
return w, nil
}
// =========================================
// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials
// =========================================
type GitCredentialHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler {
return &GitCredentialHandler{stores: s, vault: vault}
}
// Create stores a new encrypted git credential.
func (h *GitCredentialHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
AuthType string `json:"auth_type" binding:"required"`
// Raw credential data — encrypted before storage
Token string `json:"token,omitempty"` // for https_pat
Username string `json:"username,omitempty"` // for https_basic
Password string `json:"password,omitempty"` // for https_basic
PrivateKey string `json:"private_key,omitempty"` // for ssh_key
Passphrase string `json:"passphrase,omitempty"` // for ssh_key
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate auth type
switch req.AuthType {
case "https_pat":
if req.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"})
return
}
case "https_basic":
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"})
return
}
case "ssh_key":
if req.PrivateKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"})
return
}
// Build plaintext JSON
var credData map[string]string
switch req.AuthType {
case "https_pat":
credData = map[string]string{"token": req.Token}
case "https_basic":
credData = map[string]string{"username": req.Username, "password": req.Password}
case "ssh_key":
credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase}
}
plaintext, _ := json.Marshal(credData)
// Encrypt using global scope (same vault pattern as BYOK)
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
return
}
cred := &models.GitCredential{
UserID: userID,
Name: req.Name,
AuthType: req.AuthType,
EncryptedData: ciphertext,
Nonce: nonce,
}
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cred.Summary())
}
// List returns all git credentials for the current user (summaries only).
func (h *GitCredentialHandler) List(c *gin.Context) {
userID := getUserID(c)
creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
summaries := make([]models.GitCredentialSummary, 0, len(creds))
for _, cred := range creds {
summaries = append(summaries, cred.Summary())
}
c.JSON(http.StatusOK, gin.H{"data": summaries})
}
// Delete removes a git credential.
func (h *GitCredentialHandler) Delete(c *gin.Context) {
userID := getUserID(c)
credID := c.Param("id")
if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// Generate creates a new ED25519 SSH keypair server-side.
// The private key is vault-encrypted before storage. Only the public key
// and fingerprint are returned — the private key never leaves the server.
//
// POST /git-credentials/generate
func (h *GitCredentialHandler) Generate(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
PersonaID *string `json:"persona_id,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate ED25519 keypair
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
return
}
// Marshal public key to authorized_keys format
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
return
}
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
// Fingerprint (SHA256)
fingerprint := ssh.FingerprintSHA256(sshPub)
// Marshal private key to OpenSSH PEM format
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
return
}
privPEMBytes := pem.EncodeToMemory(privPEM)
// Encrypt private key via vault
credData := map[string]string{"private_key": string(privPEMBytes)}
plaintext, _ := json.Marshal(credData)
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
return
}
cred := &models.GitCredential{
UserID: userID,
Name: req.Name,
AuthType: "ssh_key",
EncryptedData: ciphertext,
Nonce: nonce,
PublicKey: authorizedKey,
Fingerprint: fingerprint,
PersonaID: req.PersonaID,
}
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, cred.Summary())
}
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
//
// GET /git-credentials/:id/public-key
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
userID := getUserID(c)
credID := c.Param("id")
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
if err != nil || cred.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
if cred.PublicKey == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
return
}
c.JSON(http.StatusOK, gin.H{
"public_key": cred.PublicKey,
"fingerprint": cred.Fingerprint,
})
}

View File

@@ -1,281 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/crypto"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Git Credentials Test Harness ──────────
type gitCredHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
}
func setupGitCredHarness(t *testing.T) *gitCredHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
// Create a test vault with a static env key (32 bytes for AES-256)
envKey := []byte("test-encryption-key-32-bytes!!")
for len(envKey) < 32 {
envKey = append(envKey, '0')
}
envKey = envKey[:32]
vault := crypto.NewKeyResolver(envKey, nil)
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
gitCredH := NewGitCredentialHandler(stores, vault)
protected.POST("/git-credentials", gitCredH.Create)
protected.POST("/git-credentials/generate", gitCredH.Generate)
protected.GET("/git-credentials", gitCredH.List)
protected.GET("/git-credentials/:id/public-key", gitCredH.GetPublicKey)
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
// Seed users
userID := database.SeedTestUser(t, "gituser", "gituser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "gituser@test.com", "user")
user2ID := database.SeedTestUser(t, "gituser2", "gituser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "gituser2@test.com", "user")
return &gitCredHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
}
}
// ── GET /git-credentials — empty state ───
func TestGitCreds_List_Empty(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr := data.([]interface{})
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── POST /git-credentials/generate ───────
func TestGitCreds_Generate(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Test Key",
})
if resp.Code != http.StatusCreated {
t.Fatalf("generate: got %d, body: %s", resp.Code, resp.Body.String())
}
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
// Must have public_key and fingerprint
pubKey, _ := cred["public_key"].(string)
fp, _ := cred["fingerprint"].(string)
if pubKey == "" {
t.Error("public_key should be non-empty")
}
if fp == "" {
t.Error("fingerprint should be non-empty")
}
if cred["auth_type"] != "ssh_key" {
t.Errorf("auth_type: got %v, want ssh_key", cred["auth_type"])
}
if cred["name"] != "Test Key" {
t.Errorf("name: got %v", cred["name"])
}
// Must NOT have encrypted data
if _, has := cred["encrypted_data"]; has {
t.Error("encrypted_data must not appear in response")
}
if _, has := cred["nonce"]; has {
t.Error("nonce must not appear in response")
}
}
// ── GET /git-credentials/:id/public-key ──
func TestGitCreds_GetPublicKey(t *testing.T) {
h := setupGitCredHarness(t)
// Generate a key first
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "PK Test",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
origPK := cred["public_key"].(string)
// Retrieve public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("get public-key: got %d, body: %s", resp.Code, resp.Body.String())
}
var pkBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&pkBody)
if pkBody["public_key"] != origPK {
t.Errorf("public key mismatch")
}
if pkBody["fingerprint"] == nil || pkBody["fingerprint"] == "" {
t.Error("fingerprint should be present")
}
}
// ── List after generate ──────────────────
func TestGitCreds_ListAfterGenerate(t *testing.T) {
h := setupGitCredHarness(t)
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key A",
})
h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Key B",
})
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 2 {
t.Fatalf("expected 2 keys, got %d", len(arr))
}
// Verify summaries have public_key + fingerprint
for _, raw := range arr {
item := raw.(map[string]interface{})
if item["public_key"] == nil || item["public_key"] == "" {
t.Error("list item should have public_key")
}
if item["fingerprint"] == nil || item["fingerprint"] == "" {
t.Error("list item should have fingerprint")
}
}
}
// ── Delete ───────────────────────────────
func TestGitCreds_Delete(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "Delete Me",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// Delete
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("delete: got %d", resp.Code)
}
// Verify gone
resp = h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("expected 0 keys after delete, got %d", len(arr))
}
}
// ── User isolation ───────────────────────
func TestGitCreds_UserIsolation(t *testing.T) {
h := setupGitCredHarness(t)
// User 1 generates a key
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{
"name": "User1 Key",
})
var cred map[string]interface{}
json.NewDecoder(resp.Body).Decode(&cred)
id := cred["id"].(string)
// User 2 should see empty list
resp = h.request("GET", "/api/v1/git-credentials", h.user2Token, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should see 0 keys, got %d", len(arr))
}
// User 2 should not be able to get user1's public key
resp = h.request("GET", "/api/v1/git-credentials/"+id+"/public-key", h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user public-key: want 404, got %d", resp.Code)
}
// User 2 should not be able to delete user1's key
resp = h.request("DELETE", "/api/v1/git-credentials/"+id, h.user2Token, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-user delete: want 404, got %d", resp.Code)
}
}
// ── Validation ───────────────────────────
func TestGitCreds_Generate_MissingName(t *testing.T) {
h := setupGitCredHarness(t)
resp := h.request("POST", "/api/v1/git-credentials/generate", h.userToken, map[string]interface{}{})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing name: want 400, got %d", resp.Code)
}
}

View File

@@ -1,733 +0,0 @@
package handlers
// import_data.go — v0.34.0 CS1
//
// Data import endpoint: user data import from .switchboard archives.
// Reads the archive, validates manifest, and imports entities in
// FK-dependency order with UUID dedup (skip if ID exists).
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/export"
"switchboard-core/models"
"switchboard-core/storage"
"switchboard-core/store"
)
// DataImportHandler serves data import endpoints.
type DataImportHandler struct {
stores store.Stores
objStore storage.ObjectStore
}
// NewDataImportHandler creates a new handler for data import operations.
func NewDataImportHandler(s store.Stores, obj storage.ObjectStore) *DataImportHandler {
return &DataImportHandler{stores: s, objStore: obj}
}
// ImportMyData imports user data from a .switchboard zip archive.
// POST /api/v1/import/me
func (h *DataImportHandler) ImportMyData(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
// ── Receive upload ──
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
// Validate extension
if !strings.HasSuffix(header.Filename, export.ExportExtension) && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .switchboard or .zip archive"})
return
}
// Size check: use MaxExportArchiveSize
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
// Save to temp file (zip.OpenReader needs a file path)
tmp, err := os.CreateTemp("", "switchboard-import-*.zip")
if err != nil {
slog.Error("import: create temp file", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
slog.Error("import: save temp file", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
// ── Open archive ──
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
defer ar.Close()
manifest, err := ar.ReadManifest()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
return
}
if manifest.FormatVersion > export.FormatVersion {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("unsupported format version %d (max %d)", manifest.FormatVersion, export.FormatVersion),
})
return
}
// ── Read entities from archive ──
imported := make(map[string]int)
skipped := make(map[string]int)
var errors []string
importEntity := func(name string, fn func() (int, int, error)) {
imp, skip, err := fn()
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
slog.Error("import: entity error", "entity", name, "error", err)
}
if imp > 0 {
imported[name] = imp
}
if skip > 0 {
skipped[name] = skip
}
}
// Helper to remap user_id on entities for cross-instance import
remapUserID := manifest.Scope != nil && manifest.Scope.UserID != "" && manifest.Scope.UserID != userID
sourceUserID := ""
if manifest.Scope != nil {
sourceUserID = manifest.Scope.UserID
}
// 1. Folders
var folders []models.Folder
if err := ar.ReadEntityJSON("folders", &folders); err == nil && len(folders) > 0 {
if remapUserID {
for i := range folders {
if folders[i].UserID == sourceUserID {
folders[i].UserID = userID
}
}
}
importEntity("folders", func() (int, int, error) {
return h.stores.Export.ImportFolders(ctx, folders)
})
}
// 2. Projects
var projects []models.Project
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
if remapUserID {
for i := range projects {
if projects[i].OwnerID == sourceUserID {
projects[i].OwnerID = userID
}
}
}
importEntity("projects", func() (int, int, error) {
return h.stores.Export.ImportProjects(ctx, projects)
})
}
// 3. Workspaces
var workspaces []models.Workspace
if err := ar.ReadEntityJSON("workspaces", &workspaces); err == nil && len(workspaces) > 0 {
if remapUserID {
for i := range workspaces {
if workspaces[i].OwnerID == sourceUserID {
workspaces[i].OwnerID = userID
}
}
}
importEntity("workspaces", func() (int, int, error) {
return h.stores.Export.ImportWorkspaces(ctx, workspaces)
})
}
// 4. Channels
var channels []models.Channel
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
if remapUserID {
for i := range channels {
if channels[i].UserID == sourceUserID {
channels[i].UserID = userID
}
}
}
importEntity("channels", func() (int, int, error) {
return h.stores.Export.ImportChannels(ctx, channels)
})
}
// 5. Channel participants
var participants []models.ChannelParticipant
if err := ar.ReadEntityJSON("channel_participants", &participants); err == nil && len(participants) > 0 {
if remapUserID {
for i := range participants {
if participants[i].ParticipantType == "user" && participants[i].ParticipantID == sourceUserID {
participants[i].ParticipantID = userID
}
}
}
importEntity("channel_participants", func() (int, int, error) {
return h.stores.Export.ImportChannelParticipants(ctx, participants)
})
}
// 5b. Channel models
var channelModels []models.ChannelModel
if err := ar.ReadEntityJSON("channel_models", &channelModels); err == nil && len(channelModels) > 0 {
importEntity("channel_models", func() (int, int, error) {
return h.stores.Export.ImportChannelModels(ctx, channelModels)
})
}
// 5c. Channel cursors
var cursors []models.ChannelCursor
if err := ar.ReadEntityJSON("channel_cursors", &cursors); err == nil && len(cursors) > 0 {
if remapUserID {
for i := range cursors {
if cursors[i].UserID == sourceUserID {
cursors[i].UserID = userID
}
}
}
importEntity("channel_cursors", func() (int, int, error) {
return h.stores.Export.ImportChannelCursors(ctx, cursors)
})
}
// 6. Messages (ordered by created_at ASC for parent_id integrity)
var messages []models.Message
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
if remapUserID {
for i := range messages {
if messages[i].ParticipantType == "user" && messages[i].ParticipantID == sourceUserID {
messages[i].ParticipantID = userID
}
}
}
importEntity("messages", func() (int, int, error) {
return h.stores.Export.ImportMessages(ctx, messages)
})
}
// 7. Notes
var notes []models.Note
if err := ar.ReadEntityJSON("notes", &notes); err == nil && len(notes) > 0 {
if remapUserID {
for i := range notes {
if notes[i].UserID == sourceUserID {
notes[i].UserID = userID
}
}
}
importEntity("notes", func() (int, int, error) {
return h.stores.Export.ImportNotes(ctx, notes)
})
}
// 8. Note links
var noteLinks []models.ExportNoteLink
if err := ar.ReadEntityJSON("note_links", &noteLinks); err == nil && len(noteLinks) > 0 {
importEntity("note_links", func() (int, int, error) {
return h.stores.Export.ImportNoteLinks(ctx, noteLinks)
})
}
// 9. Memories
var memories []models.Memory
if err := ar.ReadEntityJSON("memories", &memories); err == nil && len(memories) > 0 {
if remapUserID {
for i := range memories {
if memories[i].OwnerID == sourceUserID {
memories[i].OwnerID = userID
}
}
}
importEntity("memories", func() (int, int, error) {
return h.stores.Export.ImportMemories(ctx, memories)
})
}
// 10. Project channels
var projectChannels []models.ProjectChannel
if err := ar.ReadEntityJSON("project_channels", &projectChannels); err == nil && len(projectChannels) > 0 {
importEntity("project_channels", func() (int, int, error) {
return h.stores.Export.ImportProjectChannels(ctx, projectChannels)
})
}
// 10b. Project KBs
var projectKBs []models.ProjectKB
if err := ar.ReadEntityJSON("project_knowledge_bases", &projectKBs); err == nil && len(projectKBs) > 0 {
importEntity("project_knowledge_bases", func() (int, int, error) {
return h.stores.Export.ImportProjectKBs(ctx, projectKBs)
})
}
// 10c. Project notes
var projectNotes []models.ProjectNote
if err := ar.ReadEntityJSON("project_notes", &projectNotes); err == nil && len(projectNotes) > 0 {
importEntity("project_notes", func() (int, int, error) {
return h.stores.Export.ImportProjectNotes(ctx, projectNotes)
})
}
// 11. Workspace files
var workspaceFiles []models.WorkspaceFile
if err := ar.ReadEntityJSON("workspace_files", &workspaceFiles); err == nil && len(workspaceFiles) > 0 {
importEntity("workspace_files", func() (int, int, error) {
return h.stores.Export.ImportWorkspaceFiles(ctx, workspaceFiles)
})
}
// 12. Files (metadata)
var files []models.File
if err := ar.ReadEntityJSON("files", &files); err == nil && len(files) > 0 {
if remapUserID {
for i := range files {
if files[i].UserID == sourceUserID {
files[i].UserID = userID
}
}
}
// Generate storage keys for imported files
for i := range files {
if files[i].StorageKey == "" && files[i].ChannelID != "" {
files[i].StorageKey = fmt.Sprintf("files/%s/%s_%s", files[i].ChannelID, files[i].ID, files[i].Filename)
}
}
importEntity("files", func() (int, int, error) {
return h.stores.Export.ImportFiles(ctx, files)
})
}
// 12b. File blobs from archive
if h.objStore != nil {
fileBlobs := ar.FileEntries()
blobCount := 0
for _, entry := range fileBlobs {
if blobCount >= export.MaxExportFiles {
errors = append(errors, "file blob limit reached, some files skipped")
break
}
// Extract file ID from path: files/{fileID}/{filename}
parts := strings.SplitN(strings.TrimPrefix(entry.Name, "files/"), "/", 2)
if len(parts) != 2 {
continue
}
fileID := parts[0]
filename := parts[1]
// Find matching file record for storage key
storageKey := ""
var contentType string
for _, f := range files {
if f.ID == fileID {
storageKey = f.StorageKey
contentType = f.ContentType
break
}
}
if storageKey == "" {
storageKey = fmt.Sprintf("files/imported/%s_%s", fileID, filename)
}
if contentType == "" {
contentType = "application/octet-stream"
}
rc, err := entry.Open()
if err != nil {
errors = append(errors, fmt.Sprintf("file %s: open error", filepath.Base(entry.Name)))
continue
}
if err := h.objStore.Put(ctx, storageKey, rc, int64(entry.UncompressedSize64), contentType); err != nil {
rc.Close()
errors = append(errors, fmt.Sprintf("file %s: upload error", filepath.Base(entry.Name)))
continue
}
rc.Close()
blobCount++
}
if blobCount > 0 {
imported["file_blobs"] = blobCount
}
}
// 13. Settings/Prefs
var userModelSettings []models.UserModelSetting
if err := ar.ReadEntityJSON("user_settings", &userModelSettings); err == nil && len(userModelSettings) > 0 {
if remapUserID {
for i := range userModelSettings {
if userModelSettings[i].UserID == sourceUserID {
userModelSettings[i].UserID = userID
}
}
}
importEntity("user_settings", func() (int, int, error) {
return h.stores.Export.ImportUserModelSettings(ctx, userModelSettings)
})
}
var notifPrefs []models.NotificationPreference
if err := ar.ReadEntityJSON("notification_preferences", &notifPrefs); err == nil && len(notifPrefs) > 0 {
if remapUserID {
for i := range notifPrefs {
if notifPrefs[i].UserID == sourceUserID {
notifPrefs[i].UserID = userID
}
}
}
importEntity("notification_preferences", func() (int, int, error) {
return h.stores.Export.ImportNotifPrefs(ctx, notifPrefs)
})
}
// 14. Persona groups
var personaGroups []models.PersonaGroup
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
if remapUserID {
for i := range personaGroups {
if personaGroups[i].OwnerID == sourceUserID {
personaGroups[i].OwnerID = userID
}
}
}
importEntity("persona_groups", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
})
}
var personaGroupMembers []models.PersonaGroupMember
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
importEntity("persona_group_members", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
})
}
// ── Audit log ──
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.import",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
})
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"errors": errors,
})
}
// ImportTeam imports team data from a .switchboard archive.
// POST /api/v1/admin/teams/:id/import
func (h *DataImportHandler) ImportTeam(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
teamID := c.Param("id")
// Verify team exists
team, err := h.stores.Teams.GetByID(ctx, teamID)
if err != nil || team == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
// Receive upload
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("archive too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
tmp, err := os.CreateTemp("", "switchboard-team-import-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
defer ar.Close()
manifest, err := ar.ReadManifest()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest: " + err.Error()})
return
}
if manifest.FormatVersion > export.FormatVersion {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("unsupported format version %d", manifest.FormatVersion),
})
return
}
imported := make(map[string]int)
skipped := make(map[string]int)
var errors []string
importEntity := func(name string, fn func() (int, int, error)) {
imp, skip, err := fn()
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", name, err))
slog.Error("import: team entity error", "entity", name, "error", err)
}
if imp > 0 {
imported[name] = imp
}
if skip > 0 {
skipped[name] = skip
}
}
// Remap team_id on entities to target team
sourceTeamID := ""
if manifest.Scope != nil {
sourceTeamID = manifest.Scope.TeamID
}
remapTeam := sourceTeamID != "" && sourceTeamID != teamID
// Channels (remap team_id)
var channels []models.Channel
if err := ar.ReadEntityJSON("channels", &channels); err == nil && len(channels) > 0 {
if remapTeam {
for i := range channels {
if channels[i].TeamID != nil && *channels[i].TeamID == sourceTeamID {
channels[i].TeamID = &teamID
}
}
}
importEntity("channels", func() (int, int, error) {
return h.stores.Export.ImportChannels(ctx, channels)
})
}
// Messages
var messages []models.Message
if err := ar.ReadEntityJSON("messages", &messages); err == nil && len(messages) > 0 {
importEntity("messages", func() (int, int, error) {
return h.stores.Export.ImportMessages(ctx, messages)
})
}
// Projects (remap team_id)
var projects []models.Project
if err := ar.ReadEntityJSON("projects", &projects); err == nil && len(projects) > 0 {
if remapTeam {
for i := range projects {
if projects[i].TeamID != nil && *projects[i].TeamID == sourceTeamID {
projects[i].TeamID = &teamID
}
}
}
importEntity("projects", func() (int, int, error) {
return h.stores.Export.ImportProjects(ctx, projects)
})
}
// Persona groups (remap team_id)
var personaGroups []models.PersonaGroup
if err := ar.ReadEntityJSON("persona_groups", &personaGroups); err == nil && len(personaGroups) > 0 {
if remapTeam {
for i := range personaGroups {
if personaGroups[i].TeamID != nil && *personaGroups[i].TeamID == sourceTeamID {
personaGroups[i].TeamID = &teamID
}
}
}
importEntity("persona_groups", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroups(ctx, personaGroups)
})
}
var personaGroupMembers []models.PersonaGroupMember
if err := ar.ReadEntityJSON("persona_group_members", &personaGroupMembers); err == nil && len(personaGroupMembers) > 0 {
importEntity("persona_group_members", func() (int, int, error) {
return h.stores.Export.ImportPersonaGroupMembers(ctx, personaGroupMembers)
})
}
// Audit log
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "team.import",
ResourceType: "team",
ResourceID: teamID,
Metadata: models.JSONMap{"imported": imported, "skipped": skipped, "errors": errors},
})
c.JSON(http.StatusOK, gin.H{
"imported": imported,
"skipped": skipped,
"errors": errors,
})
}
// ImportChatGPT imports conversations from a ChatGPT export.
// POST /api/v1/import/chatgpt
func (h *DataImportHandler) ImportChatGPT(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("user_id")
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
return
}
defer file.Close()
if header.Size > export.MaxExportArchiveSize {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("file too large (max %d MB)", export.MaxExportArchiveSize/(1024*1024)),
})
return
}
// Detect if it's a zip or raw JSON
var convs export.ChatGPTExport
if strings.HasSuffix(header.Filename, ".zip") {
// Save to temp and extract conversations.json
tmp, err := os.CreateTemp("", "chatgpt-import-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, file); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
ar, err := export.OpenArchive(tmp.Name())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer ar.Close()
// Try to find conversations.json in the zip
if err := ar.ReadEntityJSON("conversations", &convs); err != nil || len(convs) == 0 {
// Try searching all files for conversations.json
for _, f := range ar.FileEntries() {
if strings.HasSuffix(f.Name, "conversations.json") {
rc, err := f.Open()
if err != nil {
continue
}
convs, err = export.ParseChatGPTExport(rc)
rc.Close()
if err == nil {
break
}
}
}
}
} else {
// Raw JSON file
convs, err = export.ParseChatGPTExport(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse conversations.json: " + err.Error()})
return
}
}
if len(convs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no conversations found in upload"})
return
}
// Convert to Switchboard models
result := export.ConvertChatGPTExport(convs, userID)
// Import channels and messages
chImported, chSkipped, err := h.stores.Export.ImportChannels(ctx, result.Channels)
if err != nil {
slog.Error("chatgpt import: channels", "error", err)
}
msgImported, msgSkipped, err := h.stores.Export.ImportMessages(ctx, result.Messages)
if err != nil {
slog.Error("chatgpt import: messages", "error", err)
}
imported := map[string]int{
"channels": chImported,
"messages": msgImported,
}
skipped := map[string]int{
"channels": chSkipped,
"messages": msgSkipped,
}
h.stores.Audit.Log(ctx, &models.AuditEntry{
ActorID: &userID,
Action: "user.import_chatgpt",
ResourceType: "user",
ResourceID: userID,
Metadata: models.JSONMap{
"conversations": len(convs),
"imported": imported,
"skipped": skipped,
},
})
c.JSON(http.StatusOK, gin.H{
"conversations": len(convs),
"imported": imported,
"skipped": skipped,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,265 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"switchboard-core/compaction"
"switchboard-core/database"
"switchboard-core/models"
"switchboard-core/roles"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
"switchboard-core/treepath"
)
// ═══════════════════════════════════════════
// Live Compaction Tests
// ═══════════════════════════════════════════
// Requires: TEST_DATABASE_URL + PROVIDER_KEY (or VENICE_API_KEY)
//
// Tests the full Compact() pipeline end-to-end:
// seed messages → call utility model → verify summary tree node
// ═══════════════════════════════════════════
// dialectStores returns the correct store bundle for the active dialect.
func dialectStores() store.Stores {
if database.IsSQLite() {
return sqlite.NewStores(database.TestDB)
}
return postgres.NewStores(database.TestDB)
}
func TestLive_CompactionFullPipeline(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
userID, adminToken := h.createAdminUser("compactadmin", "compact@test.com")
// Set up provider + enable first model
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
// Configure utility role
w := h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]interface{}{
"provider_config_id": configID,
"model_id": modelID,
},
})
if w.Code != http.StatusOK {
t.Fatalf("configure utility role: %d: %s", w.Code, w.Body.String())
}
t.Log(" ✓ Utility role configured with", modelID)
// Create a channel with enough messages to summarize
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Compaction Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Fatalf("create channel: %d: %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Seed messages manually (not via completion — faster and cheaper)
msgs := []struct{ role, content string }{
{"user", "I'm planning a trip to Japan next month. What cities should I visit?"},
{"assistant", "For a Japan trip, I'd recommend Tokyo for modern culture, Kyoto for temples and traditional culture, Osaka for food, and Hiroshima for history. How long is your trip?"},
{"user", "About 10 days. I'm most interested in food and temples."},
{"assistant", "With 10 days focusing on food and temples, I'd suggest: 3 days in Kyoto for Fushimi Inari, Kinkaku-ji, and Arashiyama. 2 days in Osaka for Dotonbori street food, Kuromon Market, and Namba. 2 days in Tokyo for Tsukiji Outer Market, Senso-ji, and Meiji Shrine. Then day trips to Nara (deer park and Todai-ji) and Kamakura (Great Buddha)."},
{"user", "That sounds great! What about budget? I'm trying to keep it under $3000 for the whole trip."},
{"assistant", "A $3000 budget for 10 days in Japan is doable. Rough breakdown: Flights ~$800-1000, Accommodation ~$50-80/night in hostels or budget hotels ($500-800), JR Pass 14-day ~$380, Food ~$30-50/day ($300-500), Activities and temples ~$200. Total: $2180-2880. Tips: eat at konbini (convenience stores) for cheap meals, visit free shrines, and book accommodation early."},
{"user", "Should I get a JR Pass or just buy individual tickets?"},
{"assistant", "For your itinerary covering Tokyo, Kyoto, Osaka, Nara, and Kamakura, a 7-day JR Pass ($200) would cover most of your intercity travel. The Tokyo-Kyoto shinkansen alone costs $120 one-way. I'd recommend the 7-day pass starting when you leave Tokyo for Kyoto, then use IC cards (Suica/Pasmo) for local transit."},
{"user", "Perfect. One more question — what's the best time to visit temples to avoid crowds?"},
{"assistant", "Early morning is best for temples. Fushimi Inari at sunrise (5-6am) is magical and nearly empty. Kinkaku-ji opens at 9am — arrive right at opening. Arashiyama bamboo grove is best before 8am. For Senso-ji in Tokyo, go at dawn for beautiful photos. Weekdays are always less crowded than weekends."},
}
ph := database.PH
var lastMsgID string
for _, m := range msgs {
var parentPtr *string
if lastMsgID != "" {
parentPtr = &lastMsgID
}
err := database.TestDB.QueryRow(
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
channelID, parentPtr, m.role, m.content).Scan(&lastMsgID)
if err != nil {
t.Fatalf("seed message: %v", err)
}
}
// Set cursor to last message
database.TestDB.Exec(
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
channelID, userID, lastMsgID)
t.Logf(" ✓ Seeded %d messages in channel %s", len(msgs), channelID)
// ── Run compaction ──
stores := dialectStores()
resolver := roles.NewResolver(stores, nil)
svc := compaction.NewService(stores, resolver)
result, err := svc.Compact(context.Background(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: nil,
Trigger: "manual",
})
if err != nil {
t.Fatalf("Compact() failed: %v", err)
}
t.Logf(" ✓ Compact result: %d messages summarized, model=%s, %d chars",
result.SummarizedCount, result.Model, len(result.Content))
// Verify summary was created
if result.SummarizedCount != len(msgs) {
t.Errorf("summarized_count = %d, want %d", result.SummarizedCount, len(msgs))
}
if result.Content == "" {
t.Fatal("summary content should not be empty")
}
if result.Model != modelID {
t.Errorf("model = %q, want %q", result.Model, modelID)
}
// Verify summary message exists in the tree
var summaryContent, summaryRole string
var metadataRaw []byte
err = database.TestDB.QueryRow(
"SELECT role, content, metadata FROM messages WHERE id = "+ph(1),
result.SummaryID).Scan(&summaryRole, &summaryContent, &metadataRaw)
if err != nil {
t.Fatalf("query summary message: %v", err)
}
if summaryRole != "assistant" {
t.Errorf("summary role = %q, want assistant", summaryRole)
}
var metadata models.JSONMap
json.Unmarshal(metadataRaw, &metadata)
if metadata["type"] != "summary" {
t.Errorf("metadata.type = %q, want summary", metadata["type"])
}
if metadata["trigger"] != "manual" {
t.Errorf("metadata.trigger = %q, want manual", metadata["trigger"])
}
t.Logf(" ✓ Summary message verified: id=%s, metadata=%v", result.SummaryID, metadata)
// Verify cursor was updated to point to summary
path, err := treepath.GetActivePath(channelID, userID)
if err != nil {
t.Fatalf("GetActivePath after compaction: %v", err)
}
if len(path) == 0 {
t.Fatal("path should not be empty after compaction")
}
lastPath := path[len(path)-1]
if lastPath.ID != result.SummaryID {
t.Errorf("active path leaf = %s, want summary %s", lastPath.ID, result.SummaryID)
}
t.Logf(" ✓ Cursor updated: path has %d messages, leaf is summary", len(path))
// Verify usage was logged
var usageCount int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM usage_log WHERE channel_id = "+ph(1)+" AND role = 'utility'",
channelID).Scan(&usageCount)
if usageCount == 0 {
t.Error("expected usage_log entry for utility role")
}
t.Logf(" ✓ Usage logged: %d entries", usageCount)
}
// TestLive_CompactionContextBudgetGuardRail verifies that Compact()
// rejects input that exceeds the utility model's context window.
func TestLive_CompactionContextBudgetGuardRail(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("guardrail_admin", "guardrail@test.com")
userID := database.SeedTestUser(t, "guardrail_user", "gruser@test.com")
// Set up provider + enable first model
configID, modelID := setupProviderWithModel(t, h, adminToken, pc)
ph := database.PH
// Set max_context to 32000 in catalog (in case sync didn't populate it)
if database.IsSQLite() {
database.TestDB.Exec(
"UPDATE model_catalog SET capabilities = json_set(COALESCE(capabilities,'{}'), '$.max_context', 32000) WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
configID, modelID)
} else {
database.TestDB.Exec(
"UPDATE model_catalog SET capabilities = capabilities || '{\"max_context\": 32000}'::jsonb WHERE provider_config_id = "+ph(1)+" AND model_id = "+ph(2),
configID, modelID)
}
// Configure utility role
h.request("PUT", "/api/v1/admin/roles/utility", adminToken, map[string]interface{}{
"primary": map[string]interface{}{
"provider_config_id": configID,
"model_id": modelID,
},
})
// Create channel with huge messages (>32K context worth)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Guard Rail Test", "type": "direct",
})
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Seed 20 messages × 8000 chars each = 160K chars ≈ 40K tokens
bigContent := make([]byte, 8000)
for i := range bigContent {
bigContent[i] = 'a'
}
var lastMsgID string
for i := 0; i < 20; i++ {
var parentPtr *string
if lastMsgID != "" {
parentPtr = &lastMsgID
}
role := "user"
if i%2 == 1 {
role = "assistant"
}
database.TestDB.QueryRow(
"INSERT INTO messages (channel_id, parent_id, role, content, sibling_index) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+", "+ph(4)+", 0) RETURNING id",
channelID, parentPtr, role, string(bigContent)).Scan(&lastMsgID)
}
database.TestDB.Exec(
"INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id) VALUES ("+ph(1)+", "+ph(2)+", "+ph(3)+") ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = excluded.active_leaf_id",
channelID, userID, lastMsgID)
// Run compaction — should fail with context budget error
stores := dialectStores()
resolver := roles.NewResolver(stores, nil)
svc := compaction.NewService(stores, resolver)
_, err := svc.Compact(context.Background(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: nil,
Trigger: "auto",
})
if err == nil {
t.Fatal("Compact() should fail when content exceeds utility model context window")
}
t.Logf(" ✓ Guard rail triggered: %v", err)
if !strings.Contains(err.Error(), "context window") {
t.Errorf("expected context window error, got: %v", err)
}
}

View File

@@ -1,812 +0,0 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"switchboard-core/database"
"switchboard-core/providers"
)
// ═══════════════════════════════════════════
// Live Provider Integration Tests
// ═══════════════════════════════════════════
// These tests require:
// - TEST_DATABASE_URL or PGHOST+PGUSER
// - PROVIDER + PROVIDER_KEY (or legacy VENICE_API_KEY)
//
// Provider config env vars:
// PROVIDER — provider type: "venice", "openai", "anthropic" (default: "venice")
// PROVIDER_KEY — API key (falls back to VENICE_API_KEY for compat)
// PROVIDER_URL — endpoint override (optional, uses default for known providers)
//
// They exercise the full flow: create provider →
// fetch models → enable model → resolve → complete.
// ═══════════════════════════════════════════
// liveProviderConfig holds resolved provider settings for live tests.
type liveProviderConfig struct {
Provider string // "venice", "openai", "anthropic"
Key string
Endpoint string
}
// defaultEndpoints maps provider names to their default API endpoints.
var defaultEndpoints = map[string]string{
"venice": "https://api.venice.ai/api/v1",
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com/v1",
"openrouter": "https://openrouter.ai/api/v1",
}
// providerKeyEnvs maps provider names to their API key env var names.
var providerKeyEnvs = map[string]string{
"venice": "VENICE_API_KEY",
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
}
// requireLiveProvider resolves the primary provider config from env vars.
// Kept for backward compat — tests that only need one provider use this.
func requireLiveProvider(t *testing.T) liveProviderConfig {
t.Helper()
key := os.Getenv("PROVIDER_KEY")
if key == "" {
// Legacy fallback
key = os.Getenv("VENICE_API_KEY")
}
if key == "" {
t.Skip("PROVIDER_KEY (or VENICE_API_KEY) not set — skipping live provider test")
}
provider := os.Getenv("PROVIDER")
if provider == "" {
provider = "venice" // default
}
endpoint := os.Getenv("PROVIDER_URL")
if endpoint == "" {
var ok bool
endpoint, ok = defaultEndpoints[provider]
if !ok {
t.Fatalf("PROVIDER=%q has no default endpoint — set PROVIDER_URL", provider)
}
}
t.Logf(" Live provider: %s @ %s", provider, endpoint)
return liveProviderConfig{Provider: provider, Key: key, Endpoint: endpoint}
}
// requireLiveProviders resolves all configured providers for failover tests.
// Reads LIVE_PROVIDERS (comma-separated, e.g. "venice,openai") and resolves
// API keys from {PROVIDER}_API_KEY env vars. Falls back to requireLiveProvider
// if LIVE_PROVIDERS is not set.
func requireLiveProviders(t *testing.T) []liveProviderConfig {
t.Helper()
list := os.Getenv("LIVE_PROVIDERS")
if list == "" {
// Fallback: just the primary provider
return []liveProviderConfig{requireLiveProvider(t)}
}
var configs []liveProviderConfig
for _, name := range splitTrim(list, ",") {
keyEnv := providerKeyEnvs[name]
if keyEnv == "" {
keyEnv = strings.ToUpper(name) + "_API_KEY"
}
key := os.Getenv(keyEnv)
if key == "" {
t.Logf(" Skipping provider %s: %s not set", name, keyEnv)
continue
}
endpoint := os.Getenv(strings.ToUpper(name) + "_API_URL")
if endpoint == "" {
endpoint = defaultEndpoints[name]
}
if endpoint == "" {
t.Logf(" Skipping provider %s: no endpoint", name)
continue
}
configs = append(configs, liveProviderConfig{Provider: name, Key: key, Endpoint: endpoint})
}
if len(configs) == 0 {
t.Skip("no live providers configured — set LIVE_PROVIDERS + API keys")
}
t.Logf(" Live providers: %d configured", len(configs))
return configs
}
// splitTrim splits s by sep and trims whitespace from each element.
func splitTrim(s, sep string) []string {
parts := strings.Split(s, sep)
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// providerModel holds a resolved (configID, modelID) pair from a provider.
type providerModel struct {
ConfigID string
ModelID string
Provider string
}
// setupAllProviders creates configs and enables a model for each provider.
// Tolerant: if a provider fails setup, it's skipped. Fails only if zero succeed.
func setupAllProviders(t *testing.T, h *testHarness, adminToken string, providerList []liveProviderConfig) []providerModel {
t.Helper()
var models []providerModel
for _, pc := range providerList {
configID, modelID, err := trySetupProvider(h, adminToken, pc)
if err != nil {
t.Logf(" ⚠ %s setup failed: %v — skipping", pc.Provider, err)
continue
}
t.Logf(" Provider %s ready, model %s enabled", pc.Provider, modelID)
models = append(models, providerModel{ConfigID: configID, ModelID: modelID, Provider: pc.Provider})
}
if len(models) == 0 {
t.Fatal("no providers available after setup — all failed")
}
return models
}
// trySetupProvider is like setupProviderWithModel but returns error instead of t.Fatal.
func trySetupProvider(h *testHarness, adminToken string, pc liveProviderConfig) (string, string, error) {
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
if w.Code != http.StatusCreated {
return "", "", fmt.Errorf("create config: %d: %s", w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch models
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
if w.Code != http.StatusOK {
return "", "", fmt.Errorf("fetch models: %d: %s", w.Code, w.Body.String())
}
// Find and enable a non-reasoning model
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string
var toolCapableCatalogID, toolCapableModelID string
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
// Only pick models from this provider
if m["provider_config_id"] != nil && m["provider_config_id"].(string) != configID {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
if fallbackCatalogID == "" {
fallbackCatalogID = cid
fallbackModelID = mid
}
isReasoning := false
hasToolCalling := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
if tc, exists := caps["tool_calling"]; exists && tc == true {
hasToolCalling = true
}
}
// Prefer non-reasoning models with tool_calling support
if !isReasoning && hasToolCalling && toolCapableCatalogID == "" {
toolCapableCatalogID = cid
toolCapableModelID = mid
}
if !isReasoning && catalogID == "" {
catalogID = cid
modelID = mid
}
}
// Prefer tool-capable model (server may inject tools into completion)
if toolCapableCatalogID != "" {
catalogID = toolCapableCatalogID
modelID = toolCapableModelID
}
if catalogID == "" {
catalogID = fallbackCatalogID
modelID = fallbackModelID
}
if catalogID == "" {
return "", "", fmt.Errorf("no disabled model found")
}
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
return "", "", fmt.Errorf("enable model: %d: %s", w.Code, w.Body.String())
}
return configID, modelID, nil
}
// tryCompletion attempts a completion against each provider/model in order.
// Returns the first successful response and the provider that worked.
// Fails only if ALL providers fail.
func tryCompletion(t *testing.T, h *testHarness, token, channelID string, models []providerModel, stream bool) (*httptest.ResponseRecorder, providerModel) {
t.Helper()
var lastW *httptest.ResponseRecorder
for _, pm := range models {
w := h.request("POST", "/api/v1/chat/completions", token, map[string]interface{}{
"channel_id": channelID,
"content": "Say ok",
"model": pm.ModelID,
"provider_config_id": pm.ConfigID,
"stream": &stream,
"max_tokens": 1200,
})
if w.Code == http.StatusOK {
t.Logf(" ✓ Completion via %s/%s (status %d)", pm.Provider, pm.ModelID, w.Code)
return w, pm
}
t.Logf(" ✗ %s/%s returned %d — trying next", pm.Provider, pm.ModelID, w.Code)
lastW = w
}
// All failed
body := ""
if lastW != nil {
body = lastW.Body.String()
}
t.Fatalf("all %d providers failed; last: %s", len(models), body)
return nil, providerModel{} // unreachable
}
// setupProviderWithModel creates a provider config, fetches models, and enables
// the first available model. Returns (configID, enabledModelID).
func setupProviderWithModel(t *testing.T, h *testHarness, adminToken string, pc liveProviderConfig) (string, string) {
t.Helper()
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create %s config: want 201, got %d: %s", pc.Provider, w.Code, w.Body.String())
}
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch models
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: %d: %s", w.Code, w.Body.String())
}
// Find and enable a model.
// Prefer non-reasoning models: they're cheaper and don't require
// minimum thinking budget tokens (which causes 400s with low max_tokens).
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
var catalogID, modelID string
var fallbackCatalogID, fallbackModelID string
for _, raw := range modelsResp["data"].([]interface{}) {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
// Save first disabled model as fallback
if fallbackCatalogID == "" {
fallbackCatalogID = cid
fallbackModelID = mid
}
// Check if this is a reasoning model — skip it if possible
isReasoning := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
}
if !isReasoning {
catalogID = cid
modelID = mid
break
}
}
// Fall back to any disabled model if all are reasoning models
if catalogID == "" {
catalogID = fallbackCatalogID
modelID = fallbackModelID
}
if catalogID == "" {
t.Fatal("no disabled model found to enable after fetch")
}
w = h.request("PUT", "/api/v1/admin/models/"+catalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: %d: %s", w.Code, w.Body.String())
}
t.Logf(" Provider %s ready, model %s enabled", configID, modelID)
return configID, modelID
}
// TestLive_ProviderFullFlow exercises the complete admin workflow:
// create provider → fetch models → enable a model → user sees it → chat completion
func TestLive_ProviderFullFlow(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// ── 1. Create provider config ────────────
t.Log("Step 1: Creating provider config")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Live Test",
"provider": pc.Provider,
"endpoint": pc.Endpoint,
"api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create config: want 201, got %d: %s", w.Code, w.Body.String())
}
var configResp map[string]interface{}
decode(w, &configResp)
configID := configResp["id"].(string)
t.Logf(" Created config: %s", configID)
// ── 2. Fetch models ─────────────────────
t.Log("Step 2: Fetching models from provider API")
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
"provider_config_id": configID,
})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetchResp map[string]interface{}
decode(w, &fetchResp)
totalFetched := fetchResp["total"].(float64)
if totalFetched < 1 {
t.Fatalf("provider should return at least 1 model, got %.0f", totalFetched)
}
t.Logf(" Fetched %.0f models", totalFetched)
// ── 3. List + enable first model ────────
t.Log("Step 3: Enabling first available model")
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
catalogModels := modelsResp["data"].([]interface{})
var enableID, enableModelID string
var fallbackID, fallbackModelID string
for _, raw := range catalogModels {
m := raw.(map[string]interface{})
if m["visibility"].(string) != "disabled" {
continue
}
mid := m["model_id"].(string)
cid := m["id"].(string)
if fallbackID == "" {
fallbackID = cid
fallbackModelID = mid
}
// Prefer non-reasoning models (cheaper, no thinking budget requirement)
isReasoning := false
if caps, ok := m["capabilities"].(map[string]interface{}); ok {
if r, exists := caps["reasoning"]; exists && r == true {
isReasoning = true
}
}
if !isReasoning {
enableID = cid
enableModelID = mid
break
}
}
if enableID == "" {
enableID = fallbackID
enableModelID = fallbackModelID
}
if enableID == "" {
t.Fatal("no disabled model found to enable")
}
t.Logf(" Enabling: %s (catalog ID: %s)", enableModelID, enableID)
w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── 4. Admin sees enabled model ─────────
t.Log("Step 4: Verifying models/enabled (admin)")
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabledResp map[string]interface{}
decode(w, &enabledResp)
enabledModels := enabledResp["data"].([]interface{})
if len(enabledModels) < 1 {
t.Fatal("models/enabled should return at least 1 model")
}
found := false
for _, raw := range enabledModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
found = true
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("enabled model must have config_id")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("enabled model must have provider_name")
}
break
}
}
if !found {
t.Errorf("model %s not found in models/enabled", enableModelID)
}
// ── 5. Regular user also sees model ─────
t.Log("Step 5: Verifying models/enabled (regular user)")
userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID)
userToken := makeToken(userID, "liveuser@test.com", "user")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var userResp map[string]interface{}
decode(w, &userResp)
userModels := userResp["data"].([]interface{})
if len(userModels) < 1 {
t.Fatal("regular user should see at least 1 enabled model")
}
}
// TestLive_FetchModelsCapabilities verifies that model capabilities
// are correctly parsed into the catalog.
func TestLive_FetchModelsCapabilities(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Caps Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
for _, raw := range resp["data"].([]interface{}) {
m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{})
if !ok {
t.Errorf("model %s: capabilities must be an object", m["model_id"])
continue
}
// streaming should be true for most providers
if caps["streaming"] != true {
t.Logf(" note: model %s streaming=%v", m["model_id"], caps["streaming"])
}
// Verify capabilities are actual booleans
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
if v, exists := caps[key]; exists {
if _, ok := v.(bool); !ok {
t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v)
}
}
}
}
}
// TestLive_ChatCompletion sends an actual non-streaming chat completion.
func TestLive_ChatCompletion(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Chat Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
w, _ = tryCompletion(t, h, adminToken, channelID, models, false)
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
}
// TestLive_UsageLogging verifies that a non-streaming completion
// creates a usage_log row with token counts.
func TestLive_UsageLogging(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
_, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, false)
var rowCount, promptTokens, completionTokens int
err := database.TestDB.QueryRow(
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after completion")
}
if promptTokens == 0 {
t.Fatal("completion should report prompt tokens")
}
t.Logf(" ✓ Usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider)
}
// TestLive_StreamingUsageLogging verifies streaming completions log usage.
func TestLive_StreamingUsageLogging(t *testing.T) {
h := setupHarness(t)
liveProvs := requireLiveProviders(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
models := setupAllProviders(t, h, adminToken, liveProvs)
w := h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Stream Usage Test", "type": "direct",
})
if w.Code != http.StatusCreated {
t.Skipf("channel creation failed: %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
_, used := tryCompletion(t, h, adminToken, ch["id"].(string), models, true)
var rowCount, promptTokens, completionTokens int
err := database.TestDB.QueryRow(
"SELECT COUNT(*), COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM usage_log WHERE provider_config_id = "+database.PH(1),
used.ConfigID).Scan(&rowCount, &promptTokens, &completionTokens)
if err != nil {
t.Fatalf("query usage_log: %v", err)
}
if rowCount == 0 {
t.Fatal("usage_log should have a row after streaming completion")
}
if promptTokens == 0 {
// Some providers (e.g. Venice) don't report token usage in streaming responses
t.Skipf("streaming completion did not report prompt tokens (provider: %s) — some providers omit streaming usage", used.Provider)
}
t.Logf(" ✓ Streaming usage: %d row(s), prompt=%d completion=%d (via %s)", rowCount, promptTokens, completionTokens, used.Provider)
}
// TestLive_PricingFromCatalog verifies model sync populates pricing.
func TestLive_PricingFromCatalog(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
configID, _ := setupProviderWithModel(t, h, adminToken, pc)
var pricingCount int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_pricing WHERE provider_config_id = "+database.PH(1),
configID).Scan(&pricingCount)
if pricingCount == 0 {
t.Skip("model sync did not populate pricing — provider may not support pricing data")
}
t.Logf(" ✓ Catalog sync populated %d pricing entries", pricingCount)
w := h.request("GET", "/api/v1/admin/pricing", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pricing: %d: %s", w.Code, w.Body.String())
}
var entries []interface{}
decode(w, &entries)
if len(entries) == 0 {
t.Error("admin pricing API should return catalog-synced entries")
}
t.Logf(" ✓ Admin pricing API returns %d entries", len(entries))
}
// TestLive_Embeddings tests the embeddings endpoint directly.
// Only runs for providers that support embeddings (currently Venice).
func TestLive_Embeddings(t *testing.T) {
pc := requireLiveProvider(t)
// Only Venice has a known embedding model for now
if pc.Provider != "venice" {
t.Skipf("embedding test only implemented for Venice (got %s)", pc.Provider)
}
provider := &providers.VeniceProvider{}
cfg := providers.ProviderConfig{
Endpoint: pc.Endpoint,
APIKey: pc.Key,
}
// Retry up to 3 times — Venice embedding endpoint can return transient 500s
var resp *providers.EmbeddingResponse
var err error
for attempt := 1; attempt <= 3; attempt++ {
resp, err = provider.Embed(
context.Background(), cfg,
providers.EmbeddingRequest{
Model: "text-embedding-bge-m3",
Input: []string{"test embedding"},
},
)
if err == nil {
break
}
t.Logf(" Embed attempt %d: %v", attempt, err)
if attempt < 3 {
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
}
if err != nil {
t.Skipf("Embed failed after 3 attempts (transient): %v", err)
}
if len(resp.Embeddings) == 0 || len(resp.Embeddings[0]) == 0 {
t.Fatal("expected non-empty embedding vector")
}
t.Logf(" ✓ Embedding: %d dimensions, input_tokens=%d",
len(resp.Embeddings[0]), resp.InputTokens)
}
// TestLive_ModelDeletion tests cleanup: delete provider removes catalog entries.
func TestLive_ModelDeletion(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": pc.Provider + " Delete Test", "provider": pc.Provider,
"endpoint": pc.Endpoint, "api_key": pc.Key,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
var count int
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
configID).Scan(&count)
if count == 0 {
t.Fatal("catalog should have models after fetch")
}
t.Logf(" %d models before delete", count)
w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
}
database.TestDB.QueryRow(
"SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = "+database.PH(1),
configID).Scan(&count)
if count != 0 {
t.Errorf("catalog should be empty after delete, got %d", count)
}
t.Log(" ✓ Cascade delete cleaned up catalog entries")
}
// TestLive_BYOK_AutoFetch exercises the user experience:
// user creates a BYOK provider → auto-fetch triggers → models appear.
func TestLive_BYOK_AutoFetch(t *testing.T) {
h := setupHarness(t)
pc := requireLiveProvider(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Enable BYOK policy
h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
map[string]interface{}{"value": "true"})
userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = "+database.PH(1), userID)
userToken := makeToken(userID, "byokuser@test.com", "user")
// User creates BYOK provider
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "My " + pc.Provider,
"provider": pc.Provider,
"endpoint": pc.Endpoint,
"api_key": pc.Key,
})
if w.Code != http.StatusCreated {
t.Fatalf("create BYOK: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
cfgID := created["id"].(string)
if created["warning"] != nil {
t.Fatalf("auto-fetch should succeed, got warning: %v", created["warning"])
}
modelsFetched := created["models_fetched"]
if modelsFetched == nil || modelsFetched.(float64) < 1 {
t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched)
}
t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64))
// Verify user sees personal models
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
var resp map[string]interface{}
decode(w, &resp)
userModels := resp["data"].([]interface{})
personalCount := 0
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] == "personal" {
personalCount++
}
}
if personalCount < 1 {
t.Fatalf("user should see personal BYOK models, got %d", personalCount)
}
t.Logf(" ✓ User sees %d personal BYOK models", personalCount)
// Cleanup
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
}

View File

@@ -1,297 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/memory"
"switchboard-core/models"
"switchboard-core/store"
)
// MemoryHandler provides REST endpoints for memory management.
type MemoryHandler struct {
stores store.Stores
compactor *memory.Compactor
}
// NewMemoryHandler creates a new memory handler.
func NewMemoryHandler(stores store.Stores) *MemoryHandler {
return &MemoryHandler{stores: stores}
}
// SetCompactor wires the compactor for the /compact endpoint.
func (h *MemoryHandler) SetCompactor(c *memory.Compactor) {
h.compactor = c
}
// ListMyMemories returns the current user's memories with optional filters.
// GET /api/v1/memories?status=active&query=...
func (h *MemoryHandler) ListMyMemories(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
status := c.DefaultQuery("status", "active")
query := c.Query("query")
memories, err := h.stores.Memories.List(c.Request.Context(), models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: status,
Query: query,
Limit: 100,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if memories == nil {
memories = []models.Memory{}
}
c.JSON(http.StatusOK, gin.H{"data": memories})
}
// UpdateMemory updates a memory's key/value/confidence.
// PUT /api/v1/memories/:id
func (h *MemoryHandler) UpdateMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
// Ownership check: user can only edit their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
var body struct {
Key *string `json:"key"`
Value *string `json:"value"`
Confidence *float64 `json:"confidence"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if body.Key != nil {
existing.Key = *body.Key
}
if body.Value != nil {
existing.Value = *body.Value
}
if body.Confidence != nil {
existing.Confidence = *body.Confidence
}
if err := h.stores.Memories.Update(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// DeleteMemory hard-deletes a memory.
// DELETE /api/v1/memories/:id
func (h *MemoryHandler) DeleteMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if err := h.stores.Memories.Delete(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ApproveMemory transitions a pending_review memory to active.
// POST /api/v1/memories/:id/approve
func (h *MemoryHandler) ApproveMemory(c *gin.Context) {
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be approved"})
return
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(c.Request.Context(), existing); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"memory": existing})
}
// RejectMemory archives a pending_review memory.
// POST /api/v1/memories/:id/reject
func (h *MemoryHandler) RejectMemory(c *gin.Context) {
userID := c.GetString("user_id")
memoryID := c.Param("id")
existing, err := h.stores.Memories.GetByID(c.Request.Context(), memoryID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "memory not found"})
return
}
// Ownership check: user can only reject their own user-scope memories
if existing.Scope == models.MemoryScopeUser && existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your memory"})
return
}
if existing.Status != models.MemoryStatusPendingReview {
c.JSON(http.StatusConflict, gin.H{"error": "only pending_review memories can be rejected"})
return
}
if err := h.stores.Memories.Archive(c.Request.Context(), memoryID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rejected": true})
}
// MemoryCount returns active + pending counts for the current user.
// GET /api/v1/memories/count
func (h *MemoryHandler) MemoryCount(c *gin.Context) {
userID := c.GetString("user_id")
ctx := c.Request.Context()
active, err := h.stores.Memories.CountByOwner(ctx, models.MemoryScopeUser, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Count pending by listing
pending, err := h.stores.Memories.List(ctx, models.MemoryFilter{
Scope: models.MemoryScopeUser,
OwnerID: userID,
Status: models.MemoryStatusPendingReview,
Limit: 1000,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"active": active,
"pending": len(pending),
})
}
// ── Admin Endpoints ──────────────────────────
// ListPendingReview returns all pending_review memories (admin only).
// GET /api/v1/admin/memories/pending
func (h *MemoryHandler) ListPendingReview(c *gin.Context) {
ctx := c.Request.Context()
memories, err := h.stores.Memories.ListByStatus(ctx, models.MemoryStatusPendingReview, 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if memories == nil {
memories = []models.Memory{}
}
c.JSON(http.StatusOK, gin.H{"data": memories})
}
// BulkApprove approves multiple memories at once.
// POST /api/v1/admin/memories/bulk-approve
func (h *MemoryHandler) BulkApprove(c *gin.Context) {
var body struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
ctx := c.Request.Context()
approved := 0
for _, id := range body.IDs {
existing, err := h.stores.Memories.GetByID(ctx, id)
if err != nil {
continue
}
if existing.Status != models.MemoryStatusPendingReview {
continue
}
existing.Status = models.MemoryStatusActive
if err := h.stores.Memories.Upsert(ctx, existing); err == nil {
approved++
}
}
c.JSON(http.StatusOK, gin.H{"approved": approved})
}
// CompactMemories triggers memory compaction for the current user.
// POST /api/v1/memories/compact
func (h *MemoryHandler) CompactMemories(c *gin.Context) {
if h.compactor == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compaction not available"})
return
}
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
teamID := c.GetString("team_id")
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.compactor.Compact(c.Request.Context(), userID, tID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"before": result.Before,
"after": result.After,
"merged": result.Merged,
"archived": result.Archived,
})
}

View File

@@ -1,100 +0,0 @@
package handlers
import (
"context"
"fmt"
"log"
"strings"
"switchboard-core/knowledge"
"switchboard-core/models"
"switchboard-core/store"
)
// maxMemoryChars is the approximate character budget for injected memories.
const maxMemoryChars = 6000
// BuildMemoryHint loads active memories for the user (and persona if active)
// and formats them as a system prompt injection.
//
// Phase 2: if an embedder is available and the user's latest message
// is provided, it generates a query vector for semantic recall. Otherwise
// falls back to keyword/confidence ranking.
func BuildMemoryHint(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID, personaID, lastUserMessage string) string {
if stores.Memories == nil {
return ""
}
var pID *string
if personaID != "" {
pID = &personaID
}
var memories []models.Memory
var err error
// Try hybrid recall with embedding if available
if embedder != nil && embedder.IsConfigured(ctx) && lastUserMessage != "" {
memories, err = hybridRecallForInjection(ctx, stores, embedder, userID, pID, lastUserMessage)
}
// Fall back to keyword recall
if err != nil || len(memories) == 0 {
memories, err = stores.Memories.Recall(ctx, userID, pID, "", 30)
}
if err != nil {
log.Printf("⚠ memory injection failed: %v", err)
return ""
}
if len(memories) == 0 {
return ""
}
// Format memories as bullet points, respecting token budget
var b strings.Builder
b.WriteString("Known facts about this user (from previous conversations):\n")
totalChars := b.Len()
included := 0
for _, m := range memories {
line := fmt.Sprintf("• %s: %s\n", m.Key, m.Value)
if totalChars+len(line) > maxMemoryChars {
break
}
b.WriteString(line)
totalChars += len(line)
included++
}
if included == 0 {
return ""
}
log.Printf("🧠 Injected %d memories for user %s (persona: %s)", included, userID, personaID)
return b.String()
}
// hybridRecallForInjection generates a query vector from the user's message
// and performs hybrid semantic + keyword recall.
func hybridRecallForInjection(ctx context.Context, stores store.Stores, embedder *knowledge.Embedder, userID string, personaID *string, message string) ([]models.Memory, error) {
text := message
if len(text) > 2000 {
text = text[:2000]
}
var teamID *string
if stores.Teams != nil {
ids, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
if len(ids) > 0 {
teamID = &ids[0]
}
}
result, err := embedder.EmbedChunks(ctx, userID, teamID, []string{text})
if err != nil || len(result.Vectors) == 0 {
return nil, err
}
return stores.Memories.RecallHybrid(ctx, userID, personaID, "", result.Vectors[0], 30)
}

View File

@@ -1,435 +0,0 @@
package handlers
import (
"net/http"
"testing"
"switchboard-core/database"
"switchboard-core/models"
)
// seedMemory inserts a memory directly into the DB for testing.
func seedMemory(t *testing.T, ownerID, key, value, status string) string {
t.Helper()
id := seedID()
q := dialectSQL(`
INSERT INTO memories (id, scope, owner_id, key, value, confidence, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`)
_, err := database.TestDB.Exec(q, id, models.MemoryScopeUser, ownerID, key, value, 0.9, status)
if err != nil {
t.Fatalf("seedMemory: %v", err)
}
return id
}
// ═══════════════════════════════════════════════
// List
// ═══════════════════════════════════════════════
func TestMemory_ListEmpty(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memuser1", "memuser1@test.com")
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Must use "data" key (envelope convention)
data, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("response must have 'data' array key, got: %s", w.Body.String())
}
if len(data) != 0 {
t.Fatalf("expected 0 memories, got %d", len(data))
}
}
func TestMemory_ListWithData(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memuser2", "memuser2@test.com")
seedMemory(t, userID, "lang", "Go", models.MemoryStatusActive)
seedMemory(t, userID, "editor", "Vim", models.MemoryStatusActive)
seedMemory(t, userID, "pending-fact", "something", models.MemoryStatusPendingReview)
// Default status=active
w := h.request("GET", "/api/v1/memories", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 active memories, got %d", len(data))
}
// Explicit status=pending_review
w = h.request("GET", "/api/v1/memories?status=pending_review", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("list pending: want 200, got %d", w.Code)
}
decode(w, &resp)
data = resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 pending memory, got %d", len(data))
}
}
func TestMemory_ListIsolation(t *testing.T) {
h := setupHarness(t)
userA, tokenA := h.createAdminUser("memA", "memA@test.com")
userB, _ := h.createAdminUser("memB", "memB@test.com")
seedMemory(t, userA, "a-fact", "user A", models.MemoryStatusActive)
seedMemory(t, userB, "b-fact", "user B", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", tokenA, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("user A should only see 1 memory, got %d", len(data))
}
mem := data[0].(map[string]interface{})
if mem["key"].(string) != "a-fact" {
t.Fatalf("wrong memory: got key %q", mem["key"])
}
}
// ═══════════════════════════════════════════════
// Update
// ═══════════════════════════════════════════════
func TestMemory_Update(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memupdater", "memupdater@test.com")
memID := seedMemory(t, userID, "old-key", "old-value", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, token, map[string]interface{}{
"key": "new-key",
"value": "new-value",
})
if w.Code != http.StatusOK {
t.Fatalf("update: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["key"].(string) != "new-key" {
t.Fatalf("key not updated: got %q", mem["key"])
}
if mem["value"].(string) != "new-value" {
t.Fatalf("value not updated: got %q", mem["value"])
}
}
func TestMemory_UpdateOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("memowner", "memowner@test.com")
_, otherToken := h.createAdminUser("memother", "memother@test.com")
memID := seedMemory(t, ownerID, "secret", "mine", models.MemoryStatusActive)
w := h.request("PUT", "/api/v1/memories/"+memID, otherToken, map[string]interface{}{
"value": "hacked",
})
if w.Code != http.StatusForbidden {
t.Fatalf("update other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_UpdateNotFound(t *testing.T) {
h := setupHarness(t)
_, token := h.createAdminUser("memghost", "memghost@test.com")
w := h.request("PUT", "/api/v1/memories/"+seedID(), token, map[string]interface{}{
"value": "x",
})
if w.Code != http.StatusNotFound {
t.Fatalf("update missing: want 404, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Delete
// ═══════════════════════════════════════════════
func TestMemory_Delete(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memdeleter", "memdeleter@test.com")
memID := seedMemory(t, userID, "to-delete", "gone", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, token, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify gone — list should be empty
w = h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 0 {
t.Fatalf("memory should be deleted, got %d", len(data))
}
}
func TestMemory_DeleteOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("delowner", "delowner@test.com")
_, otherToken := h.createAdminUser("delother", "delother@test.com")
memID := seedMemory(t, ownerID, "no-touch", "x", models.MemoryStatusActive)
w := h.request("DELETE", "/api/v1/memories/"+memID, otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("delete other's memory: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Approve
// ═══════════════════════════════════════════════
func TestMemory_Approve(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memapprover", "memapprover@test.com")
memID := seedMemory(t, userID, "pending", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
mem := resp["memory"].(map[string]interface{})
if mem["status"].(string) != models.MemoryStatusActive {
t.Fatalf("status should be active, got %q", mem["status"])
}
}
func TestMemory_ApproveStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard1", "memguard1@test.com")
// Try to approve an already-active memory
memID := seedMemory(t, userID, "already-active", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
// Try to approve an archived memory
archivedID := seedMemory(t, userID, "archived", "val", models.MemoryStatusArchived)
w = h.request("POST", "/api/v1/memories/"+archivedID+"/approve", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("approve archived memory: want 409, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Reject
// ═══════════════════════════════════════════════
func TestMemory_Reject(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memrejecter", "memrejecter@test.com")
memID := seedMemory(t, userID, "to-reject", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("reject: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
if resp["rejected"] != true {
t.Fatal("response should have rejected: true")
}
}
func TestMemory_RejectStatusGuard(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memguard2", "memguard2@test.com")
// Can't reject an already-active memory
memID := seedMemory(t, userID, "active-reject", "val", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", token, nil)
if w.Code != http.StatusConflict {
t.Fatalf("reject active memory: want 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestMemory_RejectOwnershipDenied(t *testing.T) {
h := setupHarness(t)
ownerID, _ := h.createAdminUser("rejowner", "rejowner@test.com")
_, otherToken := h.createAdminUser("rejother", "rejother@test.com")
memID := seedMemory(t, ownerID, "not-yours", "val", models.MemoryStatusPendingReview)
w := h.request("POST", "/api/v1/memories/"+memID+"/reject", otherToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("reject other's memory: want 403, got %d: %s", w.Code, w.Body.String())
}
}
// ═══════════════════════════════════════════════
// Count
// ═══════════════════════════════════════════════
func TestMemory_Count(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memcounter", "memcounter@test.com")
seedMemory(t, userID, "fact1", "a", models.MemoryStatusActive)
seedMemory(t, userID, "fact2", "b", models.MemoryStatusActive)
seedMemory(t, userID, "pend1", "c", models.MemoryStatusPendingReview)
w := h.request("GET", "/api/v1/memories/count", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("count: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
// Response shape: {"active": N, "pending": N}
active := int(resp["active"].(float64))
pending := int(resp["pending"].(float64))
if active != 2 {
t.Fatalf("expected 2 active, got %d", active)
}
if pending != 1 {
t.Fatalf("expected 1 pending, got %d", pending)
}
}
// ═══════════════════════════════════════════════
// Admin — List Pending
// ═══════════════════════════════════════════════
func TestMemory_AdminListPending(t *testing.T) {
h := setupHarness(t)
userA, _ := h.createAdminUser("adminmemA", "adminmemA@test.com")
userB, _ := h.createAdminUser("adminmemB", "adminmemB@test.com")
_, adminToken := h.createAdminUser("memadmin", "memadmin@test.com")
// Seed pending memories for both users
seedMemory(t, userA, "factA", "from A", models.MemoryStatusPendingReview)
seedMemory(t, userB, "factB", "from B", models.MemoryStatusPendingReview)
seedMemory(t, userA, "active-A", "active", models.MemoryStatusActive) // should NOT appear
w := h.request("GET", "/api/v1/admin/memories/pending", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin pending: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
if len(data) != 2 {
t.Fatalf("expected 2 pending memories across users, got %d", len(data))
}
}
func TestMemory_AdminListPendingRequiresAdmin(t *testing.T) {
h := setupHarness(t)
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_registration', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
_, userToken := h.registerUser("normalmem", "normalmem@test.com", "password123")
w := h.request("GET", "/api/v1/admin/memories/pending", userToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("non-admin should be forbidden: want 403, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Admin — Bulk Approve
// ═══════════════════════════════════════════════
func TestMemory_AdminBulkApprove(t *testing.T) {
h := setupHarness(t)
userID, _ := h.createAdminUser("bulkowner", "bulkowner@test.com")
_, adminToken := h.createAdminUser("bulkadmin", "bulkadmin@test.com")
id1 := seedMemory(t, userID, "bulk1", "a", models.MemoryStatusPendingReview)
id2 := seedMemory(t, userID, "bulk2", "b", models.MemoryStatusPendingReview)
id3 := seedMemory(t, userID, "already-active", "c", models.MemoryStatusActive)
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, map[string]interface{}{
"ids": []string{id1, id2, id3},
})
if w.Code != http.StatusOK {
t.Fatalf("bulk approve: want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
approved := int(resp["approved"].(float64))
if approved != 2 {
t.Fatalf("expected 2 approved (skipping already-active), got %d", approved)
}
}
func TestMemory_AdminBulkApproveInvalidBody(t *testing.T) {
h := setupHarness(t)
_, adminToken := h.createAdminUser("bulkbad", "bulkbad@test.com")
w := h.request("POST", "/api/v1/admin/memories/bulk-approve", adminToken, "not-json")
if w.Code != http.StatusBadRequest {
t.Fatalf("bad body: want 400, got %d", w.Code)
}
}
// ═══════════════════════════════════════════════
// Field Shape Verification
// ═══════════════════════════════════════════════
func TestMemory_FieldShape(t *testing.T) {
h := setupHarness(t)
userID, token := h.createAdminUser("memshape", "memshape@test.com")
seedMemory(t, userID, "test-key", "test-value", models.MemoryStatusActive)
w := h.request("GET", "/api/v1/memories", token, nil)
var resp map[string]interface{}
decode(w, &resp)
data := resp["data"].([]interface{})
mem := data[0].(map[string]interface{})
// Verify all expected fields exist
requiredFields := []string{"id", "scope", "owner_id", "key", "value", "confidence", "status", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := mem[f]; !ok {
t.Errorf("missing field %q in memory object", f)
}
}
// Verify no legacy "content" field
if _, ok := mem["content"]; ok {
t.Error("memory should not have 'content' field (legacy ICD)")
}
// Verify no legacy "persona_id" field
if _, ok := mem["persona_id"]; ok {
t.Error("memory should not have 'persona_id' field (legacy ICD)")
}
// Verify scope is correct
if mem["scope"].(string) != models.MemoryScopeUser {
t.Errorf("scope should be %q, got %q", models.MemoryScopeUser, mem["scope"])
}
// Confidence should be a number
confidence, ok := mem["confidence"].(float64)
if !ok || confidence < 0 || confidence > 1 {
t.Errorf("confidence should be 0-1 float, got %v", mem["confidence"])
}
}

View File

@@ -1,802 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/crypto"
"switchboard-core/database"
"switchboard-core/events"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/storage"
"switchboard-core/store"
"switchboard-core/tools"
"switchboard-core/treepath"
)
// ── Request / Response types ────────────────
type createMessageRequest struct {
Role string `json:"role" binding:"required,oneof=user assistant system"`
Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used"`
ParentID *string `json:"parent_id,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType *string `json:"participant_type,omitempty"`
ParticipantID *string `json:"participant_id,omitempty"`
SenderName *string `json:"sender_name,omitempty"`
SenderAvatar *string `json:"sender_avatar,omitempty"`
CreatedAt string `json:"created_at"`
}
type editRequest struct {
Content string `json:"content" binding:"required"`
}
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
ProviderConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
DisabledTools []string `json:"disabled_tools,omitempty"`
}
type cursorRequest struct {
ActiveLeafID string `json:"active_leaf_id" binding:"required"`
}
// MessageHandler holds dependencies for message endpoints.
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
objStore storage.ObjectStore
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
}
// ── List Messages (flat, all branches) ──────
// GET /channels/:id/messages
func (h *MessageHandler) ListMessages(c *gin.Context) {
page, perPage, offset := parsePagination(c)
userID := getUserID(c)
channelID := c.Param("id")
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
msgs, total, err := h.stores.Messages.ListWithSenderInfo(ctx, channelID, perPage, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
}
messages := make([]messageResponse, 0, len(msgs))
for _, m := range msgs {
messages = append(messages, messageResponse{
ID: m.ID,
ChannelID: m.ChannelID,
Role: m.Role,
Content: m.Content,
Model: m.Model,
TokensUsed: m.TokensUsed,
ParentID: m.ParentID,
SiblingIndex: m.SiblingIndex,
ParticipantType: m.ParticipantType,
ParticipantID: m.ParticipantID,
SenderName: m.SenderName,
SenderAvatar: m.SenderAvatar,
CreatedAt: m.CreatedAt,
})
}
// Enrich with sibling counts
for i := range messages {
messages[i].SiblingCount = getSiblingCount(channelID, messages[i].ParentID)
}
c.JSON(http.StatusOK, paginatedResponse{
Data: messages,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
}
// ── Active Path ─────────────────────────────
// GET /channels/:id/path
func (h *MessageHandler) GetActivePath(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
path, err := getActivePath(channelID, userID)
if err != nil {
log.Printf("GetActivePath error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
return
}
c.JSON(http.StatusOK, gin.H{"data": path})
}
// ── Create Message (manual) ─────────────────
// POST /channels/:id/messages
func (h *MessageHandler) CreateMessage(c *gin.Context) {
var req createMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
}
parentID, _ := getActiveLeaf(channelID, effectiveUserID)
siblingIdx := nextSiblingIndex(channelID, parentID)
participantType := "user"
participantID := userID
if isSessionAuth(c) {
participantType = "session"
participantID = c.GetString("session_id")
}
if req.Role == "assistant" {
participantType = "model"
if req.Model != "" {
participantID = req.Model
}
}
msg := &models.Message{
ChannelID: channelID,
Role: req.Role,
Content: req.Content,
Model: req.Model,
ParentID: parentID,
SiblingIndex: siblingIdx,
ParticipantType: participantType,
ParticipantID: participantID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err := h.stores.Messages.CreateWithCursor(c.Request.Context(), msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create message"})
return
}
// Broadcast via WS to channel participants
if h.hub != nil && msg.Role == "user" {
broadcastUserMessage(c.Request.Context(), h.hub, channelID, msg.ID, msg.Content, userID)
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
SiblingIndex: msg.SiblingIndex,
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if msg.Model != "" {
resp.Model = &msg.Model
}
resp.ParentID = msg.ParentID
resp.SiblingCount = getSiblingCount(channelID, msg.ParentID)
c.JSON(http.StatusCreated, resp)
}
// ── Edit Message (create sibling) ───────────
// POST /channels/:id/messages/:msgId/edit
func (h *MessageHandler) EditMessage(c *gin.Context) {
var req editRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Load target — must exist, belong to channel, be a user message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
return
}
if targetRole != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "can only edit user messages"})
return
}
// Create sibling: same parent_id as the target
siblingIdx := nextSiblingIndex(channelID, targetParentID)
msg := &models.Message{
ChannelID: channelID,
Role: "user",
Content: req.Content,
ParentID: targetParentID,
SiblingIndex: siblingIdx,
ParticipantType: "user",
ParticipantID: userID,
ToolCalls: models.JSONMap{},
Metadata: models.JSONMap{},
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create edit"})
return
}
resp := messageResponse{
ID: msg.ID,
ChannelID: msg.ChannelID,
Role: msg.Role,
Content: msg.Content,
ParentID: msg.ParentID,
SiblingIndex: msg.SiblingIndex,
SiblingCount: getSiblingCount(channelID, msg.ParentID),
CreatedAt: msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
c.JSON(http.StatusCreated, resp)
}
// ── Regenerate / Complete ──────────────────────
// POST /channels/:id/messages/:msgId/regenerate
func (h *MessageHandler) Regenerate(c *gin.Context) {
var req regenerateRequest
_ = c.ShouldBindJSON(&req) // body is optional
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Load target message
targetParentID, targetRole, err := h.stores.Messages.GetParentAndRole(ctx, messageID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load message"})
return
}
if targetRole != "assistant" && targetRole != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "can only regenerate user or assistant messages"})
return
}
// Determine context path and new message's parent based on target role
var contextPath []PathMessage
var newParentID *string
if targetRole == "assistant" {
if targetParentID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot regenerate root message"})
return
}
contextPath, err = getPathToLeaf(channelID, *targetParentID)
newParentID = targetParentID
} else {
// user message — respond to it
contextPath, err = getPathToLeaf(channelID, messageID)
newParentID = &messageID
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build context"})
return
}
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
var personaSystemPrompt string
var personaID string
model := req.Model
providerConfigID := req.ProviderConfigID
temperature := req.Temperature
maxTokens := req.MaxTokens
if req.PersonaID != "" {
persona := ResolvePersona(h.stores, req.PersonaID, userID)
if persona == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
return
}
personaID = persona.ID
if model == "" {
model = persona.BaseModelID
}
if providerConfigID == "" && persona.ProviderConfigID != nil {
providerConfigID = *persona.ProviderConfigID
}
if temperature == nil && persona.Temperature != nil {
temperature = persona.Temperature
}
if maxTokens == 0 && persona.MaxTokens != nil {
maxTokens = *persona.MaxTokens
}
if persona.SystemPrompt != "" {
personaSystemPrompt = persona.SystemPrompt
}
}
// Fallback: channel's stored model
if model == "" {
channelModel, _ := h.stores.Channels.GetDefaultModel(ctx, channelID)
if channelModel != nil {
model = *channelModel
}
}
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
Model: model,
ProviderConfigID: providerConfigID,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
provider, err := providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Build LLM message array
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
if personaSystemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
} else {
systemPrompt, _ := h.stores.Channels.GetSystemPrompt(ctx, channelID)
if systemPrompt != nil && *systemPrompt != "" {
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: *systemPrompt})
}
}
for _, m := range contextPath {
if m.Role == "system" {
continue
}
llmMessages = append(llmMessages, providers.Message{Role: m.Role, Content: m.Content})
}
provReq := providers.CompletionRequest{
Model: model,
Messages: llmMessages,
}
caps := comp.getModelCapabilities(c, model, configID)
if maxTokens > 0 {
provReq.MaxTokens = maxTokens
} else {
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if temperature != nil {
provReq.Temperature = temperature
}
// Attach tool definitions (same as normal completion)
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(ctx, channelID)
// Query channel metadata for tool context
chType, chTeamID, _ := h.stores.Channels.GetTypeAndTeamID(ctx, channelID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
TeamID: msgTeamID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(ctx, userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response ──
if hooks := providers.GetHooks(providerID); hooks != nil {
hooks.PreRequest(providerCfg, &provReq)
}
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health, comp.runner, comp.buildExtToolMap(c.Request.Context(), userID))
// Persist as sibling (regen) with tool activity
if result.Content != "" {
siblingIdx := nextSiblingIndex(channelID, newParentID)
var toolCalls models.JSONMap
if len(result.ToolActivity) > 0 {
b, _ := json.Marshal(result.ToolActivity)
_ = json.Unmarshal(b, &toolCalls)
}
if toolCalls == nil {
toolCalls = models.JSONMap{}
}
msg := &models.Message{
ChannelID: channelID,
Role: "assistant",
Content: result.Content,
Model: model,
ToolCalls: toolCalls,
Metadata: models.JSONMap{},
ParentID: newParentID,
SiblingIndex: siblingIdx,
ParticipantType: "model",
ParticipantID: model,
}
if err := h.stores.Messages.CreateWithCursor(ctx, msg, userID); err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
} else {
flusher, _ := c.Writer.(http.Flusher)
msgJSON, _ := json.Marshal(gin.H{
"id": msg.ID,
"parent_id": newParentID,
"sibling_index": siblingIdx,
"sibling_count": getSiblingCount(channelID, newParentID),
})
fmt.Fprintf(c.Writer, "data: {\"switchboard_meta\":%s}\n\n", msgJSON)
if flusher != nil {
flusher.Flush()
}
}
}
// Log usage for regeneration
comp.logUsage(c, channelID, userID, configID, providerScope, model,
result.InputTokens, result.OutputTokens,
result.CacheCreationTokens, result.CacheReadTokens)
}
// ── Switch Branch (update cursor) ───────────
// PUT /channels/:id/cursor
func (h *MessageHandler) UpdateCursor(c *gin.Context) {
var req cursorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
channelID := c.Param("id")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
ctx := c.Request.Context()
// Verify the target message belongs to this channel and is not deleted.
// GetParentAndRole does: WHERE id=$1 AND channel_id=$2 AND deleted_at IS NULL
_, _, err := h.stores.Messages.GetParentAndRole(ctx, req.ActiveLeafID, channelID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify message"})
return
}
// Walk down to the leaf from the target
leafID, err := findLeafFromMessage(req.ActiveLeafID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to find leaf"})
return
}
if err := updateCursor(channelID, userID, leafID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cursor"})
return
}
// Return the new active path
path, err := getPathToLeaf(channelID, leafID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load path"})
return
}
c.JSON(http.StatusOK, gin.H{"data": path, "active_leaf_id": leafID})
}
// ── List Siblings ───────────────────────────
// GET /channels/:id/messages/:msgId/siblings
func (h *MessageHandler) ListSiblings(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
messageID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
siblings, currentIdx, err := getSiblings(messageID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"siblings": siblings,
"current_index": currentIdx,
"total": len(siblings),
})
}
// ── Ownership / Access Check ─────────────────
// userCanAccessChannel verifies channel ownership or participation,
// writing an error response if denied. Returns true if access is allowed.
func userCanAccessChannel(c *gin.Context, stores store.Stores, channelID, userID string) bool {
ok, err := stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}
// userOwnsChannel is the legacy wrapper used by other handlers (completion.go).
// Delegates to userCanAccessChannel using the treepath global stores.
func userOwnsChannel(c *gin.Context, channelID, userID string) bool {
ok, err := treepath.Stores.Channels.UserCanAccess(c.Request.Context(), channelID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify channel access"})
return false
}
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return false
}
return true
}
// broadcastUserMessage publishes a message.created event to all user
// participants in the channel via WebSocket.
func broadcastUserMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, senderID string) {
// Look up sender display name
var displayName, username string
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(display_name, ''), COALESCE(username, '') FROM users WHERE id = $1
`), senderID).Scan(&displayName, &username)
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "user",
"content": content,
"user_id": senderID,
"display_name": displayName,
"username": username,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants in the channel
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
// Fallback: at least send to the sender
hub.PublishToUser(senderID, evt)
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil {
hub.PublishToUser(pid, evt)
}
}
}
// broadcastAssistantMessage publishes a message.created event for an assistant
// message to all user participants in the channel (except the requesting user,
// who already received the response via SSE).
func broadcastAssistantMessage(ctx context.Context, hub *events.Hub, channelID, msgID, content, model, senderUserID, personaID string) {
// Look up persona display name if available
var displayName, avatar string
if personaID != "" {
_ = database.DB.QueryRowContext(ctx, database.Q(`
SELECT COALESCE(name, ''), COALESCE(avatar, '') FROM personas WHERE id = $1
`), personaID).Scan(&displayName, &avatar)
}
if displayName == "" {
displayName = model
}
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
"role": "assistant",
"content": content,
"model": model,
"participant_type": "persona",
"participant_id": personaID,
"display_name": displayName,
"avatar": avatar,
"created_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
evt := events.Event{
Label: "message.created",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
// Send to all user participants except the sender (who got SSE)
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderUserID {
hub.PublishToUser(pid, evt)
}
}
}
// ── Delete Message ──────────────────────────────────────────
// DeleteMessage soft-deletes a message.
// DELETE /channels/:id/messages/:msgId
func (h *MessageHandler) DeleteMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
msgID := c.Param("msgId")
if !userCanAccessChannel(c, h.stores, channelID, userID) {
return
}
// Verify message belongs to this channel and is not already deleted
var exists bool
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT EXISTS(SELECT 1 FROM messages WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL)
`), msgID, channelID).Scan(&exists)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if err := h.stores.Messages.Delete(c.Request.Context(), msgID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete message"})
return
}
// Broadcast to channel participants (exclude sender — they already removed it)
broadcastMessageDeleted(c.Request.Context(), h.hub, channelID, msgID, userID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// broadcastMessageDeleted publishes a message.deleted event to all user
// participants in the channel except the sender.
func broadcastMessageDeleted(ctx context.Context, hub *events.Hub, channelID, msgID, senderID string) {
payload, _ := json.Marshal(map[string]any{
"id": msgID,
"channel_id": channelID,
})
evt := events.Event{
Label: "message.deleted",
Payload: payload,
Ts: time.Now().UnixMilli(),
}
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT participant_id FROM channel_participants
WHERE channel_id = $1 AND participant_type = 'user'
`), channelID)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var pid string
if rows.Scan(&pid) == nil && pid != senderID {
hub.PublishToUser(pid, evt)
}
}
}

View File

@@ -1,91 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ModelPrefsHandler handles user model preference endpoints.
type ModelPrefsHandler struct {
stores store.Stores
}
func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler {
return &ModelPrefsHandler{stores: s}
}
// GetPreferences returns the user's model preferences.
func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
userID := getUserID(c)
prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"})
return
}
if prefs == nil {
prefs = []models.UserModelSetting{}
}
c.JSON(http.StatusOK, gin.H{"data": prefs})
}
// SetPreference upserts a single model preference.
func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelID string `json:"model_id" binding:"required"`
ProviderConfigID string `json:"provider_config_id" binding:"required"`
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pcid := req.ProviderConfigID
patch := models.UserModelSettingPatch{
ProviderConfigID: &pcid,
Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder,
}
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, &pcid, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
}
// BulkSetPreferences sets hidden state for multiple model+provider pairs at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
Entries []models.HiddenEntry `json:"entries" binding:"required"`
Hidden bool `json:"hidden"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.Entries, req.Hidden); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.Entries)})
}

View File

@@ -1,363 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Model Prefs Test Harness ──────────────
type modelPrefsHarness struct {
*testHarness
userToken string
userID string
user2Token string
user2ID string
configID string // seeded provider_config for preference tests
}
func setupModelPrefsHarness(t *testing.T) *modelPrefsHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
modelPrefs := NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// Seed two users
userID := database.SeedTestUser(t, "prefuser", "prefuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "prefuser@test.com", "user")
user2ID := database.SeedTestUser(t, "prefuser2", "prefuser2@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), user2ID)
user2Token := makeToken(user2ID, "prefuser2@test.com", "user")
// Seed a provider config for preference targets
configID := uuid.New().String()
if database.IsSQLite() {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES (?, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
} else {
database.TestDB.Exec(`
INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope)
VALUES ($1, 'global', 'Test Provider', 'openai', 'https://api.openai.com/v1', 'global')`,
configID)
}
return &modelPrefsHarness{
testHarness: &testHarness{router: r, t: t},
userToken: userToken,
userID: userID,
user2Token: user2Token,
user2ID: user2ID,
configID: configID,
}
}
// ── GET /models/preferences — empty state ─
func TestModelPrefs_Get_Empty(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
data, ok := body["data"]
if !ok {
t.Fatal("response must have 'data' key")
}
arr, ok := data.([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(arr) != 0 {
t.Fatalf("expected empty array, got %d items", len(arr))
}
}
// ── PUT + GET round-trip ──────────────────
func TestModelPrefs_Set_RoundTrip(t *testing.T) {
h := setupModelPrefsHarness(t)
// Set preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": true,
"sort_order": 5,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT: got %d, body: %s", resp.Code, resp.Body.String())
}
// Read back
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("expected 1 preference, got %d", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["model_id"] != "test-model-1" {
t.Errorf("model_id: got %v", pref["model_id"])
}
if pref["provider_config_id"] != h.configID {
t.Errorf("provider_config_id: got %v, want %s", pref["provider_config_id"], h.configID)
}
if pref["hidden"] != true {
t.Errorf("hidden: got %v, want true", pref["hidden"])
}
if pref["sort_order"] != float64(5) {
t.Errorf("sort_order: got %v, want 5", pref["sort_order"])
}
}
// ── Upsert — no duplicate rows ────────────
func TestModelPrefs_Upsert_NoDuplicate(t *testing.T) {
h := setupModelPrefsHarness(t)
for _, hidden := range []bool{true, false, true} {
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"provider_config_id": h.configID,
"hidden": hidden,
})
if resp.Code != http.StatusOK {
t.Fatalf("PUT hidden=%v: got %d", hidden, resp.Code)
}
}
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 1 {
t.Fatalf("upsert produced %d rows, want 1", len(arr))
}
pref := arr[0].(map[string]interface{})
if pref["hidden"] != true {
t.Errorf("final hidden state should be true, got %v", pref["hidden"])
}
}
// ── Validation: missing fields ────────────
func TestModelPrefs_Set_MissingModelID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing model_id: want 400, got %d", resp.Code)
}
}
func TestModelPrefs_Set_MissingProviderConfigID(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "test-model-1",
"hidden": true,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("missing provider_config_id: want 400, got %d", resp.Code)
}
}
// ── Bulk set hidden ───────────────────────
func TestModelPrefs_BulkSetHidden(t *testing.T) {
h := setupModelPrefsHarness(t)
resp := h.request("POST", "/api/v1/models/preferences/bulk", h.userToken, map[string]interface{}{
"entries": []map[string]string{
{"model_id": "bulk-model-a", "provider_config_id": h.configID},
{"model_id": "bulk-model-b", "provider_config_id": h.configID},
},
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("bulk: got %d, body: %s", resp.Code, resp.Body.String())
}
var bulkResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&bulkResp)
if bulkResp["count"] != float64(2) {
t.Errorf("bulk count: got %v, want 2", bulkResp["count"])
}
// Verify both are hidden
resp = h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
hiddenCount := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["hidden"] == true {
hiddenCount++
}
}
if hiddenCount < 2 {
t.Errorf("expected at least 2 hidden prefs, got %d", hiddenCount)
}
}
// ── User isolation ────────────────────────
func TestModelPrefs_UserIsolation(t *testing.T) {
h := setupModelPrefsHarness(t)
// User 1 sets a preference
resp := h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "isolated-model",
"provider_config_id": h.configID,
"hidden": true,
})
if resp.Code != http.StatusOK {
t.Fatalf("user1 PUT: got %d", resp.Code)
}
// User 2 should see empty preferences
resp = h.request("GET", "/api/v1/models/preferences", h.user2Token, nil)
if resp.Code != http.StatusOK {
t.Fatalf("user2 GET: got %d", resp.Code)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) != 0 {
t.Fatalf("user2 should have 0 prefs, got %d", len(arr))
}
}
// ── Shape validation ──────────────────────
func TestModelPrefs_ResponseShape(t *testing.T) {
h := setupModelPrefsHarness(t)
// Create a preference so we have something to inspect
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "shape-model",
"provider_config_id": h.configID,
"hidden": false,
"sort_order": 0,
})
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
if len(arr) == 0 {
t.Fatal("expected at least 1 preference for shape check")
}
pref := arr[0].(map[string]interface{})
requiredFields := []string{"id", "user_id", "model_id", "provider_config_id", "hidden", "sort_order", "created_at", "updated_at"}
for _, f := range requiredFields {
if _, ok := pref[f]; !ok {
t.Errorf("missing required field %q in preference response", f)
}
}
// id, user_id, model_id, provider_config_id should be non-empty strings
for _, f := range []string{"id", "user_id", "model_id", "provider_config_id"} {
v, _ := pref[f].(string)
if v == "" {
t.Errorf("field %q should be a non-empty string, got %v", f, pref[f])
}
}
}
// ── Different provider_config_id = different entry ──
func TestModelPrefs_SameModel_DifferentProvider(t *testing.T) {
h := setupModelPrefsHarness(t)
// Seed a second provider config
config2ID := uuid.New().String()
database.TestDB.Exec(dialectSQL(
"INSERT INTO provider_configs (id, scope, name, provider, endpoint, key_scope) VALUES ($1, 'global', 'Test Provider 2', 'anthropic', 'https://api.anthropic.com/v1', 'global')"),
config2ID)
// Set preference for same model under two providers
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": h.configID,
"hidden": true,
})
h.request("PUT", "/api/v1/models/preferences", h.userToken, map[string]interface{}{
"model_id": "dual-provider-model",
"provider_config_id": config2ID,
"hidden": false,
})
// Should have two distinct entries
resp := h.request("GET", "/api/v1/models/preferences", h.userToken, nil)
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
arr := body["data"].([]interface{})
matches := 0
for _, raw := range arr {
p := raw.(map[string]interface{})
if p["model_id"] == "dual-provider-model" {
matches++
}
}
if matches != 2 {
t.Fatalf("same model, different providers: expected 2 entries, got %d", matches)
}
}

View File

@@ -1,146 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store"
)
// providerSyncTimeout is the maximum time allowed for an outbound provider
// HTTP call during model sync. Configurable via PROVIDER_SYNC_TIMEOUT env
// var (seconds). Default 30s.
var providerSyncTimeout = func() time.Duration {
if v := os.Getenv("PROVIDER_SYNC_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Second
}()
// ErrUpstreamTimeout is returned when a provider API call exceeds the
// configured timeout. Handlers use this to distinguish upstream timeouts
// from other errors and return 504 instead of 502.
var ErrUpstreamTimeout = errors.New("upstream timeout")
// syncResult holds the outcome of a model sync operation.
type syncResult struct {
Added int `json:"added"`
Updated int `json:"updated"`
Total int `json:"total"`
}
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
// apiKeyPlain is the decrypted API key — callers are responsible for decryption.
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
prov, err := providers.Get(cfg.Provider)
if err != nil {
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
}
proxyURL := ""
if cfg.ProxyURL != nil {
proxyURL = *cfg.ProxyURL
}
provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: apiKeyPlain,
ProxyMode: cfg.ProxyMode,
ProxyURL: proxyURL,
}
if cfg.Headers != nil {
customHeaders := make(map[string]string)
for k, v := range cfg.Headers {
if s, ok := v.(string); ok {
customHeaders[k] = s
}
}
provCfg.CustomHeaders = customHeaders
}
// Parse config for any extra settings the provider needs
if cfg.Config != nil {
raw, _ := json.Marshal(cfg.Config)
var extra map[string]string
if json.Unmarshal(raw, &extra) == nil {
if provCfg.CustomHeaders == nil {
provCfg.CustomHeaders = make(map[string]string)
}
for k, v := range extra {
provCfg.CustomHeaders[k] = v
}
}
}
// Wrap context with timeout for the outbound provider call.
// All provider ListModels implementations use http.NewRequestWithContext,
// so the deadline propagates to the HTTP transport automatically.
syncCtx, cancel := context.WithTimeout(ctx, providerSyncTimeout)
defer cancel()
provModels, err := prov.ListModels(syncCtx, provCfg)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return syncResult{}, fmt.Errorf("%w: %s", ErrUpstreamTimeout, cfg.Provider)
}
return syncResult{}, err
}
syncEntries := make([]store.CatalogSyncEntry, len(provModels))
for i, m := range provModels {
syncEntries[i] = store.CatalogSyncEntry{
ModelID: m.ID,
DisplayName: m.Name,
ModelType: m.Type,
Capabilities: m.Capabilities,
Pricing: m.Pricing,
}
}
added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries)
if err != nil {
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
}
// Sync pricing from provider catalog (won't overwrite manual admin overrides)
if stores.Pricing != nil {
for _, m := range provModels {
if m.Pricing != nil {
if err := stores.Pricing.UpsertFromCatalog(ctx, cfg.ID, m.ID, m.Pricing); err != nil {
log.Printf("warn: pricing sync for %s/%s failed: %v", cfg.ID, m.ID, err)
}
}
}
}
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
}
// syncAndEnableProviderModels fetches models and auto-enables them all.
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig, apiKeyPlain string) (syncResult, error) {
result, err := syncProviderModels(ctx, stores, cfg, apiKeyPlain)
if err != nil {
return result, err
}
// Auto-enable: user added this key to USE it, not to stare at disabled models
if result.Total > 0 {
if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil {
log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err)
}
}
return result, nil
}

View File

@@ -1,602 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/notelinks"
"switchboard-core/store"
)
// ── Request / Response Types ────────────────
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
type updateNoteRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
FolderPath *string `json:"folder_path"`
Tags []string `json:"tags"`
// Mode controls how content is applied: "replace" (default), "append", "prepend"
Mode string `json:"mode"`
}
type noteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
SourceMessageID *string `json:"source_message_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type noteListItem struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
Preview string `json:"preview"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type searchResult struct {
noteListItem
Rank float64 `json:"rank"`
Headline string `json:"headline"`
}
// NoteHandler handles notes CRUD.
type NoteHandler struct {
stores store.Stores
}
// NewNoteHandler creates a new handler.
func NewNoteHandler(s ...store.Stores) *NoteHandler {
h := &NoteHandler{}
if len(s) > 0 {
h.stores = s[0]
}
return h
}
// toNoteResponse converts a models.Note to a noteResponse.
func toNoteResponse(n *models.Note) noteResponse {
tags := n.Tags
if tags == nil {
tags = []string{}
}
return noteResponse{
ID: n.ID,
Title: n.Title,
Content: n.Content,
FolderPath: n.FolderPath,
Tags: tags,
SourceChannelID: n.SourceChannelID,
SourceMessageID: n.SourceMessageID,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNoteListItem(n models.Note) noteListItem {
tags := n.Tags
if tags == nil {
tags = []string{}
}
preview := n.Content
if len(preview) > 200 {
preview = preview[:200]
}
return noteListItem{
ID: n.ID,
Title: n.Title,
FolderPath: n.FolderPath,
Tags: tags,
Preview: preview,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ── Create ──────────────────────────────────
// POST /api/v1/notes
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
folder := normalizeFolderPath(req.FolderPath)
tags := req.Tags
if tags == nil {
tags = []string{}
}
var sourceChannelID *string
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var sourceMessageID *string
if req.SourceMessageID != "" {
sourceMessageID = &req.SourceMessageID
}
note := &models.Note{
UserID: userID,
Title: req.Title,
Content: req.Content,
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
SourceMessageID: sourceMessageID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
return
}
// Extract and store wikilinks
h.extractAndStoreLinks(c, note.ID, note.Content, userID)
// Resolve dangling links from other notes that reference this title
if h.stores.NoteLinks != nil {
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
}
// Auto-associate note with source channel's project (v0.19.0)
if req.SourceChannelID != "" && h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(
c.Request.Context(), req.SourceChannelID)
if projID != "" {
_ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID)
}
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
// ── Get ─────────────────────────────────────
// GET /api/v1/notes/:id
func (h *NoteHandler) Get(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Verify ownership
if note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
// ── Update ──────────────────────────────────
// PUT /api/v1/notes/:id
func (h *NoteHandler) Update(c *gin.Context) {
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Build fields map
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Content != nil {
mode := strings.ToLower(req.Mode)
switch mode {
case "append":
fields["content"] = existing.Content + *req.Content
case "prepend":
fields["content"] = *req.Content + existing.Content
default: // "replace" or empty
fields["content"] = *req.Content
}
}
if req.FolderPath != nil {
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
return
}
// Re-fetch to get updated timestamps
updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"})
return
}
// Re-extract links if content changed
if req.Content != nil {
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
// ── Delete ──────────────────────────────────
// DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Bulk Delete ────────────────────────────
// POST /api/v1/notes/bulk-delete
func (h *NoteHandler) BulkDelete(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
return
}
if len(req.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
return
}
userID := getUserID(c)
count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": count})
}
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
func (h *NoteHandler) List(c *gin.Context) {
userID := getUserID(c)
folder := c.Query("folder")
tag := c.Query("tag")
sort := c.DefaultQuery("sort", "updated_desc")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 200 {
limit = 50
}
opts := store.NoteListOptions{
ListOptions: store.ListOptions{
Limit: limit,
Offset: offset,
},
FolderPath: normalizeFolderPath(folder),
Tag: tag,
}
if folder == "" {
opts.FolderPath = ""
}
// Map sort parameter
switch sort {
case "created_asc":
opts.Sort = "created_at"
opts.Order = "ASC"
case "created_desc":
opts.Sort = "created_at"
opts.Order = "DESC"
case "updated_asc":
opts.Sort = "updated_at"
opts.Order = "ASC"
case "title_asc":
opts.Sort = "title"
opts.Order = "ASC"
case "title_desc":
opts.Sort = "title"
opts.Order = "DESC"
default: // "updated_desc"
opts.Sort = ""
opts.Order = ""
}
notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
items := make([]noteListItem, 0, len(notes))
for _, n := range notes {
items = append(items, toNoteListItem(n))
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── Search ──────────────────────────────────
// GET /api/v1/notes/search?q=query&limit=20
func (h *NoteHandler) Search(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 || limit > 100 {
limit = 20
}
// SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE.
storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
results := make([]searchResult, 0, len(storeResults))
for _, sr := range storeResults {
tags := sr.Tags
if tags == nil {
tags = []string{}
}
results = append(results, searchResult{
noteListItem: noteListItem{
ID: sr.ID,
Title: sr.Title,
FolderPath: sr.FolderPath,
Tags: tags,
Preview: sr.Excerpt,
},
Rank: sr.Rank,
Headline: sr.Headline,
})
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// ── List Folders ────────────────────────────
// GET /api/v1/notes/folders
func (h *NoteHandler) ListFolders(c *gin.Context) {
userID := getUserID(c)
storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
type folderInfo struct {
Path string `json:"path"`
Count int `json:"count"`
}
folders := make([]folderInfo, 0, len(storeFolders))
for _, f := range storeFolders {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
}
c.JSON(http.StatusOK, gin.H{"data": folders})
}
// ── Helpers ─────────────────────────────────
// normalizeFolderPath ensures consistent folder path format.
func normalizeFolderPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
// Collapse double slashes
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
return p
}
// ── Wikilink Endpoints ──────────────────────
// GET /api/v1/notes/search-titles?q=query&limit=10
func (h *NoteHandler) SearchTitles(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
type titleResult struct {
ID string `json:"id"`
Title string `json:"title"`
}
results := make([]titleResult, 0, len(notes))
for _, n := range notes {
results = append(results, titleResult{ID: n.ID, Title: n.Title})
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/:id/backlinks
func (h *NoteHandler) Backlinks(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"})
return
}
if results == nil {
results = []models.NoteLinkResult{}
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/graph
func (h *NoteHandler) Graph(c *gin.Context) {
userID := getUserID(c)
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, models.NoteGraph{
Nodes: []models.NoteGraphNode{},
Edges: []models.NoteGraphEdge{},
Unresolved: []models.NoteGraphDangling{},
})
return
}
graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"})
return
}
c.JSON(http.StatusOK, graph)
}
// ── Link Extraction Helper ──────────────────
// extractAndStoreLinks parses [[wikilinks]] from content and stores them.
func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) {
if h.stores.NoteLinks == nil {
return
}
links := notelinks.ExtractWikilinks(content)
if len(links) == 0 {
// Clear any existing links
h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil)
return
}
// Resolve titles to note IDs
ctx := c.Request.Context()
for i, link := range links {
notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1)
if err == nil {
for _, n := range notes {
if strings.EqualFold(n.Title, link.TargetTitle) {
id := n.ID
links[i].TargetNoteID = &id
break
}
}
}
}
h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links)
}

View File

@@ -1,127 +0,0 @@
package handlers
// package_export.go — v0.30.0 CS3
//
// Exports an installed package as a downloadable .pkg archive for
// cross-instance sharing. Includes manifest, static assets, and
// Starlark scripts. Does NOT include extension table data or secrets.
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/store"
)
// PackageExportHandler handles package export operations.
type PackageExportHandler struct {
stores store.Stores
packagesDir string
}
// NewPackageExportHandler creates a new export handler.
func NewPackageExportHandler(s store.Stores, packagesDir string) *PackageExportHandler {
return &PackageExportHandler{stores: s, packagesDir: packagesDir}
}
// ExportPackage downloads an installed package as a .pkg archive.
// GET /api/v1/admin/packages/:id/export
func (h *PackageExportHandler) ExportPackage(c *gin.Context) {
id := c.Param("id")
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
if err != nil || pkg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
if pkg.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot export core packages"})
return
}
// Build manifest for export
manifest := make(map[string]any, len(pkg.Manifest))
for k, v := range pkg.Manifest {
// Skip internal fields that shouldn't be exported
if k == "_script" {
continue
}
manifest[k] = v
}
// Include package_settings as default_settings so importing instance
// gets admin defaults (but not secrets)
if len(pkg.PackageSettings) > 0 && string(pkg.PackageSettings) != "{}" {
var ps map[string]any
if json.Unmarshal(pkg.PackageSettings, &ps) == nil && len(ps) > 0 {
manifest["default_settings"] = ps
}
}
// Set response headers
filename := fmt.Sprintf("%s-%s.pkg", id, pkg.Version)
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Create zip writer directly to response
zw := zip.NewWriter(c.Writer)
defer zw.Close()
// Write manifest.json
manifestJSON, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to serialize manifest"})
return
}
mf, err := zw.Create("manifest.json")
if err != nil {
return
}
mf.Write(manifestJSON)
// Include Starlark script as script.star if present
if script, ok := pkg.Manifest["_starlark_script"].(string); ok && script != "" {
sf, err := zw.Create("script.star")
if err == nil {
sf.Write([]byte(script))
}
}
// Walk packagesDir/{id}/ for static assets
if h.packagesDir != "" {
assetsDir := filepath.Join(h.packagesDir, id)
if info, err := os.Stat(assetsDir); err == nil && info.IsDir() {
filepath.Walk(assetsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, err := filepath.Rel(assetsDir, path)
if err != nil {
return nil
}
// Normalize path separators for zip
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
f, err := zw.Create(relPath)
if err != nil {
return nil
}
src, err := os.Open(path)
if err != nil {
return nil
}
defer src.Close()
io.Copy(f, src)
return nil
})
}
}
}

View File

@@ -1,160 +0,0 @@
package handlers
// package_migrations.go — v0.30.0 CS1
//
// Schema migration engine for extension packages. When a package declares
// schema_version and migrations in its manifest, this engine runs Starlark
// migration scripts on install (fresh or upgrade).
//
// Manifest format:
// {
// "schema_version": 2,
// "migrations": {
// "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})",
// "2": "def migrate(db):\n ..."
// }
// }
//
// On fresh install: runs migrations 1..schema_version.
// On upgrade: runs migrations (current+1)..new_schema_version.
// On downgrade: rejected (409).
import (
"context"
"database/sql"
"fmt"
"log"
"strconv"
"go.starlark.net/starlark"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// ParseSchemaVersion extracts the "schema_version" integer from a manifest.
// Returns 0 if not present or not a number.
func ParseSchemaVersion(manifest map[string]any) int {
raw, ok := manifest["schema_version"]
if !ok {
return 0
}
switch v := raw.(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// ParseMigrations extracts the "migrations" map from a manifest.
// Keys are string integers ("1", "2", ...), values are Starlark source code.
func ParseMigrations(manifest map[string]any) map[int]string {
raw, ok := manifest["migrations"]
if !ok {
return nil
}
migrationsRaw, ok := raw.(map[string]any)
if !ok || len(migrationsRaw) == 0 {
return nil
}
result := make(map[int]string, len(migrationsRaw))
for key, val := range migrationsRaw {
version, err := strconv.Atoi(key)
if err != nil {
log.Printf("[pkg-migrate] skipping non-integer migration key %q", key)
continue
}
script, ok := val.(string)
if !ok || script == "" {
log.Printf("[pkg-migrate] skipping empty migration for version %d", version)
continue
}
result[version] = script
}
return result
}
// RunSchemaMigrations executes Starlark migration scripts from fromVersion+1
// to toVersion, updating schema_version in the store after each successful step.
//
// The sandbox runs each migration script with a db module at write level,
// scoped to the package's namespace. This bypasses the permission system
// because migrations are admin-initiated.
//
// On error, returns immediately — partial migration state is tracked via
// the per-step schema_version update.
func RunSchemaMigrations(
ctx context.Context,
sb *sandbox.Sandbox,
stores store.Stores,
db *sql.DB,
isPostgres bool,
packageID string,
manifest map[string]any,
fromVersion int,
toVersion int,
) error {
if fromVersion >= toVersion {
return nil
}
migrations := ParseMigrations(manifest)
if migrations == nil && toVersion > 0 {
// No migration scripts but schema_version declared — just set the version
return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion)
}
for step := fromVersion + 1; step <= toVersion; step++ {
script, ok := migrations[step]
if !ok {
// No script for this step — just bump the version
log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step)
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
continue
}
log.Printf("[pkg-migrate] %s: running migration %d", packageID, step)
// Build a db module at write level for the migration
dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{
PackageID: packageID,
CanWrite: true,
DB: db,
IsPostgres: isPostgres,
})
modules := map[string]starlark.Value{
"db": dbModule,
}
// Execute the migration script
result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules)
if err != nil {
return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err)
}
// Call migrate(db) if defined
if migrateFn, ok := result.Globals["migrate"]; ok {
if callable, ok := migrateFn.(starlark.Callable); ok {
_, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil)
if err != nil {
return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err)
}
}
}
// Mark this step as complete
if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil {
return fmt.Errorf("set schema_version to %d: %w", step, err)
}
log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step)
}
return nil
}

View File

@@ -1,360 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Channel Participants Handler ──────────────
// ICD §3.7: Manages polymorphic participants per channel.
// Routes:
// GET /channels/:id/participants — list
// POST /channels/:id/participants — add
// PATCH /channels/:id/participants/:participantId — update role
// DELETE /channels/:id/participants/:participantId — remove
// GET /channels/:id/presence — who's online
type ParticipantHandler struct {
stores store.Stores
}
func NewParticipantHandler(stores store.Stores) *ParticipantHandler {
return &ParticipantHandler{stores: stores}
}
// ── List ─────────────────────────────────────
func (h *ParticipantHandler) List(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Caller must be a participant (any role)
if !h.requireParticipant(c, channelID, userID) {
return
}
participants, err := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list participants"})
return
}
if participants == nil {
participants = []models.ChannelParticipant{}
}
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Add ──────────────────────────────────────
type addParticipantRequest struct {
ParticipantType string `json:"participant_type" binding:"required"`
ParticipantID string `json:"participant_id" binding:"required"`
Role string `json:"role"`
}
func (h *ParticipantHandler) Add(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// Only owners can add participants
if !h.requireOwner(c, channelID, userID) {
return
}
var req addParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate participant_type
switch req.ParticipantType {
case "user", "persona", "session":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "participant_type must be user, persona, or session"})
return
}
// Default role
role := req.Role
if role == "" {
role = "member"
}
switch role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Build participant
p := &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: req.ParticipantType,
ParticipantID: req.ParticipantID,
Role: role,
}
// If adding a persona, resolve display_name and avatar from persona record,
// and auto-add persona's model to channel_models roster.
if req.ParticipantType == "persona" {
persona, err := h.stores.Personas.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up persona"})
return
}
dn := persona.Name
p.DisplayName = &dn
if persona.Avatar != "" {
p.AvatarURL = &persona.Avatar
}
// Auto-add persona's model to channel_models roster
cm := &models.ChannelModel{
ChannelID: channelID,
ModelID: persona.BaseModelID,
ProviderConfigID: derefStr(persona.ProviderConfigID),
PersonaID: &req.ParticipantID,
Handle: persona.Handle,
DisplayName: persona.Name,
SystemPrompt: persona.SystemPrompt,
IsDefault: false,
}
_ = h.stores.Channels.SetModel(c.Request.Context(), cm)
} else if req.ParticipantType == "user" {
// Resolve user display name
user, err := h.stores.Users.GetByID(c.Request.Context(), req.ParticipantID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up user"})
return
}
dn := user.DisplayName
if dn == "" {
dn = user.Username
}
p.DisplayName = &dn
if user.AvatarURL != "" {
p.AvatarURL = &user.AvatarURL
}
}
if err := h.stores.Channels.AddParticipant(c.Request.Context(), p); err != nil {
// Check for duplicate
if isDuplicateErr(err) {
c.JSON(http.StatusConflict, gin.H{"error": "participant already in channel"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
// Return updated participant list
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusCreated, gin.H{"data": participants})
}
// ── Update Role ─────────────────────────────
type updateParticipantRequest struct {
Role string `json:"role" binding:"required"`
}
func (h *ParticipantHandler) Update(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
var req updateParticipantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch req.Role {
case "owner", "member", "observer":
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, member, or observer"})
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
if err := h.stores.Channels.UpdateParticipantRole(c.Request.Context(), participantRecordID, req.Role); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Remove ──────────────────────────────────
func (h *ParticipantHandler) Remove(c *gin.Context) {
channelID := c.Param("id")
participantRecordID := c.Param("participantId")
userID := getUserID(c)
if !h.requireOwner(c, channelID, userID) {
return
}
// Verify participant belongs to this channel
p, err := h.stores.Channels.GetParticipantByID(c.Request.Context(), participantRecordID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to look up participant"})
return
}
if p.ChannelID != channelID {
c.JSON(http.StatusForbidden, gin.H{"error": "participant does not belong to this channel"})
return
}
// Cannot remove the last owner
if p.Role == "owner" {
owners, _ := h.stores.Channels.CountParticipantsByRole(c.Request.Context(), channelID, "owner")
if owners <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot remove the last owner"})
return
}
}
// v0.23.2: DM guard — cannot drop below 2 human participants
channelType, _, _ := h.stores.Channels.GetTypeAndTeamID(c.Request.Context(), channelID)
if channelType == "dm" && p.ParticipantType == "user" {
userCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "user")
if userCount <= 2 {
c.JSON(http.StatusConflict, gin.H{"error": "DMs require at least 2 participants"})
return
}
}
// v0.23.2: Group guard — cannot remove the last persona
if channelType == "group" && p.ParticipantType == "persona" {
personaCount, _ := h.stores.Channels.CountParticipantsByType(c.Request.Context(), channelID, "persona")
if personaCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "group chats require at least 1 persona"})
return
}
}
// If removing a persona, also remove its auto-created model roster entry
if p.ParticipantType == "persona" {
_ = h.stores.Channels.DeleteModelByPersona(c.Request.Context(), channelID, p.ParticipantID)
}
if err := h.stores.Channels.RemoveParticipant(c.Request.Context(), participantRecordID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
participants, _ := h.stores.Channels.ListParticipants(c.Request.Context(), channelID)
c.JSON(http.StatusOK, gin.H{"data": participants})
}
// ── Helpers ──────────────────────────────────
// requireParticipant checks the user is any participant in the channel.
func (h *ParticipantHandler) requireParticipant(c *gin.Context, channelID, userID string) bool {
ok, err := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check participation"})
return false
}
if !ok {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
return true
}
// requireOwner checks the user is an owner participant in the channel.
func (h *ParticipantHandler) requireOwner(c *gin.Context, channelID, userID string) bool {
role, err := h.stores.Channels.GetParticipantRole(c.Request.Context(), channelID, "user", userID)
if err != nil {
// Fall back to legacy channels.user_id ownership for direct channels
if userOwnsChannel(c, channelID, userID) {
return true
}
return false
}
if role != "owner" {
c.JSON(http.StatusForbidden, gin.H{"error": "owner role required"})
return false
}
return true
}
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// isDuplicateErr detects unique constraint violations across both Postgres and SQLite.
func isDuplicateErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// Postgres: "duplicate key value violates unique constraint"
// SQLite: "UNIQUE constraint failed"
return contains(msg, "duplicate key") || contains(msg, "UNIQUE constraint")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@@ -1,202 +0,0 @@
package handlers
// persona_groups.go — Persona group (roster template) CRUD (v0.23.2)
//
// v0.29.0: Raw SQL replaced with PersonaGroupStore methods.
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
type PersonaGroupHandler struct {
stores store.Stores
}
func NewPersonaGroupHandler(s store.Stores) *PersonaGroupHandler {
return &PersonaGroupHandler{stores: s}
}
// ── List ────────────────────────────────────────
func (h *PersonaGroupHandler) List(c *gin.Context) {
userID := getUserID(c)
groups, err := h.stores.PersonaGroups.List(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groups"})
return
}
for i := range groups {
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), groups[i].ID)
groups[i].Members = members
}
c.JSON(http.StatusOK, gin.H{"data": groups})
}
// ── Get ─────────────────────────────────────────
func (h *PersonaGroupHandler) Get(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
g, err := h.stores.PersonaGroups.Get(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get group"})
return
}
if g == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
members, _ := h.stores.PersonaGroups.ListMembers(c.Request.Context(), g.ID)
if members == nil {
members = []models.PersonaGroupMember{}
}
g.Members = members
c.JSON(http.StatusOK, g)
}
// ── Create ──────────────────────────────────────
func (h *PersonaGroupHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
g := &models.PersonaGroup{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
OwnerID: userID,
Scope: "personal",
}
if err := h.stores.PersonaGroups.Create(c.Request.Context(), g); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create group"})
return
}
g.Members = []models.PersonaGroupMember{}
c.JSON(http.StatusCreated, g)
}
// ── Update ──────────────────────────────────────
func (h *PersonaGroupHandler) Update(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), id)
if err != nil || ownerID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
if ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = strings.TrimSpace(*req.Name)
}
if req.Description != nil {
fields["description"] = *req.Description
}
if len(fields) > 0 {
_ = h.stores.PersonaGroups.Update(c.Request.Context(), id, fields)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Delete ──────────────────────────────────────
func (h *PersonaGroupHandler) Delete(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
n, err := h.stores.PersonaGroups.Delete(c.Request.Context(), id, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── Add Member ──────────────────────────────────
func (h *PersonaGroupHandler) AddMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
var req struct {
PersonaID string `json:"persona_id" binding:"required"`
IsLeader bool `json:"is_leader"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
if err := h.stores.PersonaGroups.AddMember(c.Request.Context(), groupID, req.PersonaID, req.IsLeader); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add member"})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true})
}
// ── Remove Member ───────────────────────────────
func (h *PersonaGroupHandler) RemoveMember(c *gin.Context) {
userID := getUserID(c)
groupID := c.Param("id")
memberID := c.Param("memberId")
ownerID, err := h.stores.PersonaGroups.GetOwnerID(c.Request.Context(), groupID)
if err != nil || ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your group"})
return
}
_ = h.stores.PersonaGroups.RemoveMember(c.Request.Context(), memberID, groupID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -1,569 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// PersonaHandler handles persona endpoints.
type PersonaHandler struct {
stores store.Stores
}
func NewPersonaHandler(s store.Stores) *PersonaHandler {
return &PersonaHandler{stores: s}
}
// ── User Personas (personal scope) ──────────
func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
userID := getUserID(c)
personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"})
return
}
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopePersonal
persona.OwnerID = &userID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
// Users can only edit their own personal personas
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"})
return
}
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"})
return
}
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Team Personas ───────────────────────────
func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
teamID := c.Param("teamId")
personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
userID := getUserID(c)
teamID := c.Param("teamId")
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeTeam
persona.OwnerID = &teamID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
// UpdateTeamPersona updates a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path.
func (h *PersonaHandler) UpdateTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
// DeleteTeamPersona deletes a team-scoped persona. Verifies the persona
// belongs to the team specified in the URL path before deleting.
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Admin Personas (global scope) ───────────
func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
personas, err := h.stores.Personas.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
if personas == nil {
personas = []models.Persona{}
}
c.JSON(http.StatusOK, gin.H{"data": personas})
}
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
userID := getUserID(c)
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeGlobal
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) {
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Request Types ───────────────────────────
type personaRequest struct {
Name string `json:"name" binding:"required"`
Handle string `json:"handle,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsShared bool `json:"is_shared,omitempty"`
}
func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{
Name: r.Name,
Handle: r.Handle,
Description: r.Description,
Icon: r.Icon,
BaseModelID: r.BaseModelID,
ProviderConfigID: r.ProviderConfigID,
SystemPrompt: r.SystemPrompt,
Temperature: r.Temperature,
MaxTokens: r.MaxTokens,
ThinkingBudget: r.ThinkingBudget,
TopP: r.TopP,
IsActive: true,
IsShared: r.IsShared,
}
return p
}
// ResolvePersona loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona {
ctx := context.Background()
p, err := stores.Personas.GetByID(ctx, personaID)
if err != nil || !p.IsActive {
return nil
}
ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID)
if err != nil || !ok {
return nil
}
return p
}
// ── Persona-KB Binding Endpoints (v0.17.0) ──────
// GetPersonaKBs returns the knowledge bases bound to a persona.
func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// GetTeamPersonaKBs returns KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// SetPersonaKBs replaces the knowledge bases bound to a persona.
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// SetTeamPersonaKBs replaces KBs bound to a persona, verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaKBs(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
KBIDs []string `json:"kb_ids"`
AutoSearch map[string]bool `json:"auto_search"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Persona Tool Grants (v0.25.0) ───────────
// GetPersonaToolGrants returns the tool names granted to a persona.
// Empty list = persona inherits all context-available tools.
func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetPersonaToolGrants replaces the tool grants for a persona.
// Sending an empty tool_names array clears all grants (persona inherits all tools).
func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert tool names to Grant objects
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── User-Scoped Persona Avatar (with ownership check) ─────
// UploadUserPersonaAvatar uploads an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) UploadUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteUserPersonaAvatar deletes an avatar for a personal persona,
// verifying the caller owns it.
func (h *PersonaHandler) DeleteUserPersonaAvatar(c *gin.Context) {
userID := getUserID(c)
personaID := c.Param("id")
// Verify ownership
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify this persona's avatar"})
return
}
// Delegate to the shared avatar handler
DeletePersonaAvatar(h.stores.Personas, c)
}
// ── Team-Scoped Helpers ─────────────────────
// requireTeamPersona loads a persona by ID and verifies it belongs to
// the team in the URL path. Returns the persona on success or writes
// an error response and returns nil.
func (h *PersonaHandler) requireTeamPersona(c *gin.Context) *models.Persona {
teamID := c.Param("teamId")
personaID := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return nil
}
if existing.Scope != models.ScopeTeam || existing.OwnerID == nil || *existing.OwnerID != teamID {
c.JSON(http.StatusForbidden, gin.H{"error": "persona does not belong to this team"})
return nil
}
return existing
}
// ── Team-Scoped Persona Tool Grants ─────────
// GetTeamPersonaToolGrants returns tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) GetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
if grants == nil {
grants = []string{}
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetTeamPersonaToolGrants replaces tool grants for a team persona,
// verifying team ownership.
func (h *PersonaHandler) SetTeamPersonaToolGrants(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Team-Scoped Persona Avatar ──────────────
// UploadTeamPersonaAvatar uploads an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) UploadTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
UploadPersonaAvatar(h.stores.Personas, c)
}
// DeleteTeamPersonaAvatar deletes an avatar for a team persona,
// verifying team ownership.
func (h *PersonaHandler) DeleteTeamPersonaAvatar(c *gin.Context) {
if p := h.requireTeamPersona(c); p == nil {
return
}
DeletePersonaAvatar(h.stores.Personas, c)
}

View File

@@ -1,461 +0,0 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// ── Request / Response Types ────────────────
type createProjectRequest struct {
Name string `json:"name" binding:"required,max=200"`
Description string `json:"description,omitempty"`
Color *string `json:"color,omitempty"`
Icon *string `json:"icon,omitempty"`
}
type updateProjectRequest = models.ProjectPatch
type addChannelRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
Position int `json:"position"`
}
type reorderChannelsRequest struct {
ChannelIDs []string `json:"channel_ids" binding:"required"`
}
type addKBRequest struct {
KBID string `json:"kb_id" binding:"required"`
AutoSearch bool `json:"auto_search"`
}
type addNoteRequest struct {
NoteID string `json:"note_id" binding:"required"`
}
// ProjectHandler handles project CRUD and associations.
type ProjectHandler struct {
stores store.Stores
}
// NewProjectHandler creates a new project handler.
func NewProjectHandler(s store.Stores) *ProjectHandler {
return &ProjectHandler{stores: s}
}
// ── CRUD ────────────────────────────────────
func (h *ProjectHandler) List(c *gin.Context) {
userID := getUserID(c)
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
includeArchived := c.Query("include_archived") == "true"
projects, err := h.stores.Projects.ListForUser(c.Request.Context(), userID, teamIDs, includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
if projects == nil {
projects = []models.Project{}
}
c.JSON(http.StatusOK, gin.H{"data": projects})
}
func (h *ProjectHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req createProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p := &models.Project{
Name: req.Name,
Description: req.Description,
Color: req.Color,
Icon: req.Icon,
Scope: models.ScopePersonal,
OwnerID: userID,
}
if err := h.stores.Projects.Create(c.Request.Context(), p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"})
return
}
c.JSON(http.StatusCreated, p)
}
func (h *ProjectHandler) Get(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
c.JSON(http.StatusOK, project)
}
func (h *ProjectHandler) Update(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req updateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.Update(c.Request.Context(), project.ID, req); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update project"})
return
}
// Return refreshed project
updated, err := h.stores.Projects.GetByID(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload project"})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *ProjectHandler) Delete(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Only owner or admin can delete
userID := getUserID(c)
if project.OwnerID != userID {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "only the project owner can delete it"})
return
}
}
if err := h.stores.Projects.Delete(c.Request.Context(), project.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete project"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "project deleted"})
}
// ── Channel Association ─────────────────────
func (h *ProjectHandler) AddChannel(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify the user owns the channel
userID := getUserID(c)
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), req.ChannelID, userID)
if err != nil || !owns {
c.JSON(http.StatusForbidden, gin.H{"error": "channel not found or not owned"})
return
}
if err := h.stores.Projects.AddChannel(c.Request.Context(), project.ID, req.ChannelID, req.Position); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add channel"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channel added"})
}
func (h *ProjectHandler) RemoveChannel(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
channelID := c.Param("channelId")
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id required"})
return
}
if err := h.stores.Projects.RemoveChannel(c.Request.Context(), project.ID, channelID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove channel"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channel removed"})
}
func (h *ProjectHandler) ListChannels(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
channels, err := h.stores.Projects.ListChannels(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
return
}
if channels == nil {
channels = []models.ProjectChannel{}
}
c.JSON(http.StatusOK, gin.H{"data": channels})
}
func (h *ProjectHandler) ReorderChannels(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req reorderChannelsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.ReorderChannels(c.Request.Context(), project.ID, req.ChannelIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder channels"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "channels reordered"})
}
// ── KB Association ──────────────────────────
func (h *ProjectHandler) AddKB(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addKBRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.AddKB(c.Request.Context(), project.ID, req.KBID, req.AutoSearch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add KB"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "KB added"})
}
func (h *ProjectHandler) RemoveKB(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
kbID := c.Param("kbId")
if kbID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "kb_id required"})
return
}
if err := h.stores.Projects.RemoveKB(c.Request.Context(), project.ID, kbID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "KB not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove KB"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "KB removed"})
}
func (h *ProjectHandler) ListKBs(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
kbs, err := h.stores.Projects.ListKBs(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list KBs"})
return
}
if kbs == nil {
kbs = []models.ProjectKB{}
}
c.JSON(http.StatusOK, gin.H{"data": kbs})
}
// ── Note Association ────────────────────────
func (h *ProjectHandler) AddNote(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req addNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Projects.AddNote(c.Request.Context(), project.ID, req.NoteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add note"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "note added"})
}
func (h *ProjectHandler) RemoveNote(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
noteID := c.Param("noteId")
if noteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "note_id required"})
return
}
if err := h.stores.Projects.RemoveNote(c.Request.Context(), project.ID, noteID); err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "note not in project"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove note"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "note removed"})
}
func (h *ProjectHandler) ListNotes(c *gin.Context) {
project, ok := h.loadAndAuthorize(c)
if !ok {
return
}
notes, err := h.stores.Projects.ListNotes(c.Request.Context(), project.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
if notes == nil {
notes = []models.ProjectNote{}
}
c.JSON(http.StatusOK, gin.H{"data": notes})
}
// ── Auth Helper ─────────────────────────────
// ── Admin ────────────────────────────────────
// AdminList returns all projects (no scope filtering). Admin only.
func (h *ProjectHandler) AdminList(c *gin.Context) {
ctx := c.Request.Context()
includeArchived := c.Query("include_archived") == "true"
projects, err := h.stores.Projects.AdminList(ctx, includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
type adminProject struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Scope string `json:"scope"`
OwnerID string `json:"owner_id"`
TeamID *string `json:"team_id,omitempty"`
IsArchived bool `json:"is_archived"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ChannelCount int `json:"channel_count"`
KBCount int `json:"kb_count"`
NoteCount int `json:"note_count"`
OwnerName string `json:"owner_name"`
}
result := make([]adminProject, 0, len(projects))
for _, p := range projects {
result = append(result, adminProject{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Scope: p.Scope,
OwnerID: p.OwnerID,
TeamID: p.TeamID,
IsArchived: p.IsArchived,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ChannelCount: p.ChannelCount,
KBCount: p.KBCount,
NoteCount: p.NoteCount,
OwnerName: p.OwnerName,
})
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// ── Auth Helper ─────────────────────────────
// loadAndAuthorize loads the project by :id param and checks user access.
// Returns (project, true) on success, or writes an error response and returns (nil, false).
func (h *ProjectHandler) loadAndAuthorize(c *gin.Context) (*models.Project, bool) {
projectID := c.Param("id")
if projectID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project id required"})
return nil, false
}
userID := getUserID(c)
// Admins can access all projects
if c.GetString("role") != "admin" {
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID)
ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "access check failed"})
return nil, false
}
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return nil, false
}
}
project, err := h.stores.Projects.GetByID(c.Request.Context(), projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load project"})
return nil, false
}
return project, true
}

View File

@@ -1,373 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/models"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Project Test Harness ──────────────────
type projectHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
adminToken string
adminID string
}
func setupProjectHarness(t *testing.T) *projectHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
projH := NewProjectHandler(stores)
protected.GET("/projects", projH.List)
protected.POST("/projects", projH.Create)
protected.GET("/projects/:id", projH.Get)
protected.PUT("/projects/:id", projH.Update)
protected.DELETE("/projects/:id", projH.Delete)
protected.POST("/projects/:id/channels", projH.AddChannel)
protected.DELETE("/projects/:id/channels/:channelId", projH.RemoveChannel)
protected.GET("/projects/:id/channels", projH.ListChannels)
protected.PUT("/projects/:id/channels/reorder", projH.ReorderChannels)
protected.POST("/projects/:id/knowledge-bases", projH.AddKB)
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projH.RemoveKB)
protected.GET("/projects/:id/knowledge-bases", projH.ListKBs)
protected.POST("/projects/:id/notes", projH.AddNote)
protected.DELETE("/projects/:id/notes/:noteId", projH.RemoveNote)
protected.GET("/projects/:id/notes", projH.ListNotes)
// Admin routes
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
admin.Use(middleware.RequireAdmin())
admin.GET("/projects", projH.AdminList)
admin.DELETE("/projects/:id", projH.Delete)
userID := database.SeedTestUser(t, "projuser", "projuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "projuser@test.com", "user")
adminID := database.SeedTestUser(t, "projadmin", "projadmin@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, role = 'admin' WHERE id = $1"), adminID)
adminToken := makeToken(adminID, "projadmin@test.com", "admin")
return &projectHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
adminToken: adminToken,
adminID: adminID,
}
}
// seedProject creates a project via the store and returns it.
func (h *projectHarness) seedProject(name, ownerID string) *models.Project {
h.t.Helper()
p := &models.Project{
Name: name,
Scope: models.ScopePersonal,
OwnerID: ownerID,
}
if err := h.stores.Projects.Create(context.Background(), p); err != nil {
h.t.Fatalf("seedProject: %v", err)
}
return p
}
// ── GET /projects — envelope ──────────────
func TestProjects_List_Empty_Envelope(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"]
if !ok {
t.Fatal("GET /projects must return {\"data\": [...]}, missing \"data\" key")
}
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
func TestProjects_List_WithData_Shape(t *testing.T) {
h := setupProjectHarness(t)
h.seedProject("test-proj", h.userID)
resp := h.request("GET", "/api/v1/projects", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(data) != 1 {
t.Fatalf("expected 1 project, got %d", len(data))
}
proj := data[0].(map[string]interface{})
for _, key := range []string{"id", "name", "scope", "owner_id", "is_archived", "created_at", "updated_at"} {
if _, exists := proj[key]; !exists {
t.Errorf("missing field %q in project object", key)
}
}
if proj["name"] != "test-proj" {
t.Errorf("name: got %v, want test-proj", proj["name"])
}
}
// ── POST /projects — create ──────────────
func TestProjects_Create_201_Shape(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
"name": "new-project",
"description": "test desc",
"color": "#ff0000",
"icon": "star",
})
if resp.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Bare object, not wrapped in "data"
if _, exists := body["data"]; exists {
t.Error("POST /projects should return bare object, not wrapped in 'data'")
}
if body["name"] != "new-project" {
t.Errorf("name: got %v, want new-project", body["name"])
}
if body["scope"] != "personal" {
t.Errorf("scope should be forced to personal, got %v", body["scope"])
}
if body["owner_id"] != h.userID {
t.Errorf("owner_id: got %v, want %s", body["owner_id"], h.userID)
}
if body["color"] != "#ff0000" {
t.Errorf("color: got %v, want #ff0000", body["color"])
}
if body["icon"] != "star" {
t.Errorf("icon: got %v, want star", body["icon"])
}
}
func TestProjects_Create_RequiresName(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("POST", "/api/v1/projects", h.userToken, map[string]interface{}{
"description": "no name",
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing name, got %d", resp.Code)
}
}
// ── GET /projects/:id — single object ─────
func TestProjects_Get_Shape(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("detail-proj", h.userID)
resp := h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Bare object
if _, exists := body["data"]; exists {
t.Error("GET /:id should return object directly, not wrapped in 'data'")
}
if body["id"] != p.ID {
t.Errorf("id: got %v, want %s", body["id"], p.ID)
}
// Computed counts use omitempty — absent when zero, present as number when nonzero.
// With a fresh project all counts are 0, so they'll be omitted. Verify that if
// present they're numeric (the AdminList test covers nonzero count visibility).
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
if val, exists := body[key]; exists {
if _, ok := val.(float64); !ok {
t.Errorf("%s should be numeric, got %T", key, val)
}
}
}
}
func TestProjects_Get_NotFound(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects/00000000-0000-0000-0000-000000000000", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
}
// ── PUT /projects/:id — update ────────────
func TestProjects_Update_ReturnsRefreshed(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("old-name", h.userID)
resp := h.request("PUT", "/api/v1/projects/"+p.ID, h.userToken, map[string]interface{}{
"name": "new-name",
})
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
if body["name"] != "new-name" {
t.Errorf("name should be updated to new-name, got %v", body["name"])
}
if body["id"] != p.ID {
t.Errorf("id mismatch: got %v, want %s", body["id"], p.ID)
}
}
// ── DELETE /projects/:id ──────────────────
func TestProjects_Delete_Shape(t *testing.T) {
h := setupProjectHarness(t)
p := h.seedProject("doomed", h.userID)
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
if body["message"] != "project deleted" {
t.Errorf("expected message 'project deleted', got %v", body["message"])
}
// Confirm actually deleted
resp = h.request("GET", "/api/v1/projects/"+p.ID, h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Errorf("project should be gone, got %d", resp.Code)
}
}
func TestProjects_Delete_ForbiddenForNonOwner(t *testing.T) {
h := setupProjectHarness(t)
otherID := database.SeedTestUser(t, "other", "other@test.com")
p := h.seedProject("others-proj", otherID)
resp := h.request("DELETE", "/api/v1/projects/"+p.ID, h.userToken, nil)
// Non-owner, non-admin user should not be able to access (404 from loadAndAuthorize)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404 for other user's personal project, got %d", resp.Code)
}
}
// ── Auth ──────────────────────────────────
func TestProjects_RequiresAuth(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/projects", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}
// ── Admin List ────────────────────────────
func TestProjects_AdminList_Envelope(t *testing.T) {
h := setupProjectHarness(t)
h.seedProject("admin-visible", h.userID)
resp := h.request("GET", "/api/v1/admin/projects", h.adminToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("admin list must return {\"data\": [...]}")
}
if len(data) < 1 {
t.Fatal("expected at least 1 project in admin list")
}
proj := data[0].(map[string]interface{})
// Admin list has extra owner_name field
if _, exists := proj["owner_name"]; !exists {
t.Error("admin list project should include owner_name")
}
for _, key := range []string{"channel_count", "kb_count", "note_count"} {
if _, exists := proj[key]; !exists {
t.Errorf("admin list missing computed field %q", key)
}
}
}
func TestProjects_AdminList_ForbiddenForUser(t *testing.T) {
h := setupProjectHarness(t)
resp := h.request("GET", "/api/v1/admin/projects", h.userToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-admin, got %d", resp.Code)
}
}

View File

@@ -1,51 +0,0 @@
// Package handlers — provider_resolver_adapter.go
//
// v0.29.1 CS2: Adapter that satisfies sandbox.ProviderResolver using
// ResolveProviderConfig + providers.Get. Avoids circular dependency
// (sandbox → handlers → sandbox) by having handlers implement the
// sandbox-defined interface.
package handlers
import (
"context"
"fmt"
"switchboard-core/crypto"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
)
// ProviderResolverAdapter implements sandbox.ProviderResolver using
// the existing ResolveProviderConfig function and provider registry.
type ProviderResolverAdapter struct {
stores store.Stores
vault *crypto.KeyResolver
}
// NewProviderResolverAdapter creates an adapter for Starlark provider resolution.
func NewProviderResolverAdapter(stores store.Stores, vault *crypto.KeyResolver) *ProviderResolverAdapter {
return &ProviderResolverAdapter{stores: stores, vault: vault}
}
// Resolve satisfies sandbox.ProviderResolver. It resolves the provider
// config via the BYOK chain and returns the provider instance + config.
func (a *ProviderResolverAdapter) Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*sandbox.ProviderResolution, error) {
res, err := ResolveProviderConfig(a.stores, a.vault, userID, channelID, providerConfigID, model)
if err != nil {
return nil, err
}
provider, err := providers.Get(res.ProviderID)
if err != nil {
return nil, fmt.Errorf("provider unavailable: %w", err)
}
return &sandbox.ProviderResolution{
Provider: provider,
Config: res.Config,
ProviderID: res.ProviderID,
Model: res.Model,
ConfigID: res.ConfigID,
}, nil
}

View File

@@ -1,306 +0,0 @@
// Package handlers — resolve.go
//
// v0.27.2: Extracted provider resolution and tool definition building
// from CompletionHandler methods to standalone functions. Both the HTTP
// completion handler and the headless task scheduler need these paths.
//
// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as
// thin wrappers for backward compat — existing callers are unchanged.
//
// v0.29.0-cs7a: Replaced raw SQL with store methods.
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"switchboard-core/crypto"
"switchboard-core/providers"
"switchboard-core/store"
"switchboard-core/tools"
)
// ── Provider Resolution ────────────────────────
// ProviderResolution holds the resolved provider configuration and metadata.
type ProviderResolution struct {
Config providers.ProviderConfig
ProviderID string // e.g. "anthropic", "openai"
Model string // resolved model ID
ConfigID string // provider_configs.id
ProviderScope string // "personal", "team", "global"
}
// ResolveProviderConfig resolves the provider configuration for a completion
// request. Resolution order:
// 1. Explicit providerConfigID (from request or task definition)
// 2. Channel's configured provider (provider_config_id on channels table)
// 3. User's first active config (personal first, then global)
//
// The vault is used to decrypt API keys. Pass nil for unencrypted fallback.
func ResolveProviderConfig(
stores store.Stores,
vault *crypto.KeyResolver,
userID, channelID, providerConfigID, modelID string,
) (ProviderResolution, error) {
ctx := context.Background()
configID := providerConfigID
// 2. Config from channel
if configID == "" && channelID != "" {
channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
}
}
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
var err error
configID, err = stores.Providers.FindFirstForUser(ctx, userID)
if err != nil {
return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config — allow personal, global, OR team configs the user belongs to
cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID)
if err == sql.ErrNoRows {
return ProviderResolution{}, fmt.Errorf("API config not found or not accessible")
}
if err != nil {
return ProviderResolution{}, fmt.Errorf("failed to load API config: %w", err)
}
// Resolve model: explicit > config default
model := modelID
if model == "" && cfg.ModelDefault != "" {
model = cfg.ModelDefault
}
if model == "" {
return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config")
}
// Decrypt API key using the appropriate tier
key := ""
if len(cfg.APIKeyEnc) > 0 {
if vault != nil {
var err error
key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again")
}
return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
key = string(cfg.APIKeyEnc)
}
}
// Parse custom headers
customHeaders := make(map[string]string)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
// Parse provider-specific settings
providerSettings := make(map[string]interface{})
if cfg.Settings != nil {
for k, v := range cfg.Settings {
providerSettings[k] = v
}
}
proxyMode := cfg.ProxyMode
if proxyMode == "" {
proxyMode = "system"
}
proxyURLStr := ""
if cfg.ProxyURL != nil {
proxyURLStr = *cfg.ProxyURL
}
return ProviderResolution{
Config: providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
},
ProviderID: cfg.Provider,
Model: model,
ConfigID: cfg.ID,
ProviderScope: cfg.Scope,
}, nil
}
// ── Tool Definition Building ───────────────────
// BuildToolDefs assembles the tool definitions for a completion request.
// Includes server-registered tools filtered by context predicates,
// browser extension tools (if includeBrowser), and persona tool grant
// allowlisting.
//
// Additional tool grant filtering (e.g. task-level grants) can be applied
// by the caller after this function returns.
func BuildToolDefs(
ctx context.Context,
stores store.Stores,
userID string,
includeBrowser bool,
disabledTools []string,
tctx tools.ToolContext,
personaID string,
) []providers.ToolDef {
// Build disabled set for O(1) lookup
disabled := make(map[string]bool, len(disabledTools))
for _, name := range disabledTools {
disabled[name] = true
}
// v0.25.0: Tools self-declare availability via predicates.
allTools := tools.AvailableFor(tctx, disabled)
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
// Append browser-defined and starlark-tier tool schemas from extensions.
// v0.29.2: starlark tools are always included (not gated on browser connection).
if stores.Packages != nil {
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, pkg := range pkgs {
// Browser tools only when a browser client is connected.
if pkg.Tier == "browser" && !includeBrowser {
continue
}
// Only browser and starlark tiers expose tools this way.
if pkg.Tier != "browser" && pkg.Tier != "starlark" {
continue
}
// Starlark packages must be active to expose tools.
if pkg.Tier == "starlark" && pkg.Status != "active" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"tools"`
}
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {
if disabled[t.Name] {
continue
}
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
}
// v0.25.0: Persona tool grants — second-pass allowlist.
// If the active persona has explicit tool grants, restrict to only those tools.
// Empty grants = persona inherits all context-available tools (backward compat).
if personaID != "" && stores.Personas != nil {
grants, err := stores.Personas.GetToolGrants(ctx, personaID)
if err != nil {
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
} else if len(grants) > 0 {
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := defs[:0]
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
defs = filtered
}
}
return defs
}
// BuildExtToolMap returns a map of toolName → PackageRegistration for all
// active starlark-tier extensions that declare tools in their manifest.
// Used by the tool loop to dispatch matched calls to on_tool_call.
func BuildExtToolMap(ctx context.Context, stores store.Stores, userID string) map[string]*store.PackageRegistration {
if stores.Packages == nil {
return nil
}
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
return nil
}
result := make(map[string]*store.PackageRegistration)
for i, pkg := range pkgs {
if pkg.Tier != "starlark" || pkg.Status != "active" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if json.Unmarshal(marshalManifest(pkg.Manifest), &manifest) != nil {
continue
}
for _, t := range manifest.Tools {
if t.Name != "" {
result[t.Name] = &pkgs[i].PackageRegistration
}
}
}
return result
}
// FilterToolDefsByGrants applies an additional allowlist to tool defs.
// Used by the task scheduler to enforce task-level tool grants on top
// of persona-level grants. Passing nil or empty grants returns defs unchanged.
func FilterToolDefsByGrants(defs []providers.ToolDef, grants []string) []providers.ToolDef {
if len(grants) == 0 {
return defs
}
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := make([]providers.ToolDef, 0, len(defs))
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
return filtered
}

View File

@@ -1,243 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// RolesHandler manages model role configuration.
type RolesHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewRolesHandler creates a roles handler.
func NewRolesHandler(s store.Stores, resolver *roles.Resolver) *RolesHandler {
return &RolesHandler{stores: s, resolver: resolver}
}
// ── List All Role Configs ──────────────────
// GET /admin/roles
func (h *RolesHandler) ListRoles(c *gin.Context) {
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
c.JSON(http.StatusOK, allRoles)
}
// ── Get Single Role Config ─────────────────
// GET /admin/roles/:role
func (h *RolesHandler) GetRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
cfg, err := h.resolver.GetConfig(c.Request.Context(), role, "", nil)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
// ── Update Role Config ─────────────────────
// PUT /admin/roles/:role
func (h *RolesHandler) UpdateRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load current global model_roles
allRoles, err := h.stores.GlobalConfig.Get(c.Request.Context(), "model_roles")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load roles"})
return
}
if allRoles == nil {
allRoles = models.JSONMap{}
}
// Update the specific role
allRoles[role] = req
// Persist
userID := getUserID(c)
if err := h.stores.GlobalConfig.Set(c.Request.Context(), "model_roles", allRoles, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save role"})
return
}
c.JSON(http.StatusOK, req)
}
// ── Test Role ──────────────────────────────
// POST /admin/roles/:role/test
func (h *RolesHandler) TestRole(c *gin.Context) {
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
if role == roles.RoleEmbedding {
// Test embedding
result, err := h.resolver.Embed(c.Request.Context(), role, "", nil, []string{"test embedding"})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"dimensions": len(result.Embeddings[0]),
"used_fallback": result.UsedFallback,
})
return
}
// Test completion with a minimal prompt
result, err := h.resolver.Complete(c.Request.Context(), role, "", nil, []providers.Message{
{Role: "user", Content: "Say 'ok' and nothing else."},
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"model": result.Model,
"provider": result.ProviderID,
"content": result.Content,
"input_tokens": result.InputTokens,
"output_tokens": result.OutputTokens,
"used_fallback": result.UsedFallback,
})
}
// ── Team Role Overrides ────────────────────
// ListTeamRoles returns role overrides for a specific team.
// GET /teams/:teamId/roles
func (h *RolesHandler) ListTeamRoles(c *gin.Context) {
teamID := c.Param("teamId")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
roleOverrides := make(map[string]interface{})
if team.Settings != nil {
if raw, ok := team.Settings["model_roles"]; ok {
if m, ok := raw.(map[string]interface{}); ok {
roleOverrides = m
}
}
}
c.JSON(http.StatusOK, roleOverrides)
}
// UpdateTeamRole sets a team role override.
// PUT /teams/:teamId/roles/:role
func (h *RolesHandler) UpdateTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
if !roles.IsValidRole(role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown role: " + role})
return
}
var req roles.RoleConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
settings = models.JSONMap{}
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides == nil {
roleOverrides = make(map[string]interface{})
}
roleOverrides[role] = req
settings["model_roles"] = roleOverrides
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save team role"})
return
}
c.JSON(http.StatusOK, req)
}
// DeleteTeamRole removes a team role override (falls back to global).
// DELETE /teams/:teamId/roles/:role
func (h *RolesHandler) DeleteTeamRole(c *gin.Context) {
teamID := c.Param("teamId")
role := c.Param("role")
team, err := h.stores.Teams.GetByID(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "team not found"})
return
}
settings := team.Settings
if settings == nil {
c.JSON(http.StatusOK, gin.H{"message": "no override to remove"})
return
}
roleOverrides, _ := settings["model_roles"].(map[string]interface{})
if roleOverrides != nil {
delete(roleOverrides, role)
settings["model_roles"] = roleOverrides
}
if err := h.stores.Teams.Update(c.Request.Context(), teamID, map[string]interface{}{
"settings": settings,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove team role"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "override removed"})
}

View File

@@ -1,242 +0,0 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/health"
"switchboard-core/models"
"switchboard-core/routing"
"switchboard-core/store"
)
// ── Routing Admin Handler ───────────────────
type RoutingAdminHandler struct {
stores store.Stores
evaluator *routing.Evaluator
healthStore health.Store
}
func NewRoutingAdminHandler(stores store.Stores, evaluator *routing.Evaluator, hs health.Store) *RoutingAdminHandler {
return &RoutingAdminHandler{stores: stores, evaluator: evaluator, healthStore: hs}
}
// ── CRUD ────────────────────────────────────
// ListPolicies returns all routing policies.
// GET /api/v1/admin/routing/policies
func (h *RoutingAdminHandler) ListPolicies(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusOK, gin.H{"data": []models.RoutingPolicy{}})
return
}
policies, err := h.stores.RoutingPolicies.ListAll(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list policies: " + err.Error()})
return
}
if policies == nil {
policies = []models.RoutingPolicy{}
}
c.JSON(http.StatusOK, gin.H{"data": policies})
}
// GetPolicy returns a single routing policy.
// GET /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) GetPolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "routing not available"})
return
}
p, err := h.stores.RoutingPolicies.GetByID(context.Background(), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
c.JSON(http.StatusOK, p)
}
// CreatePolicy creates a new routing policy.
// POST /api/v1/admin/routing/policies
func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
var p models.RoutingPolicy
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate policy type
switch routing.PolicyType(p.Type) {
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
routing.PolicyCostLimit, routing.PolicyModelAlias,
routing.PolicyCapabilityMatch:
// valid
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
return
}
// Validate scope
if p.Scope != "global" && p.Scope != "team" {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be 'global' or 'team'"})
return
}
if p.Scope == "team" && (p.TeamID == nil || *p.TeamID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped policies"})
return
}
if err := h.stores.RoutingPolicies.Create(context.Background(), &p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create policy: " + err.Error()})
return
}
c.JSON(http.StatusCreated, p)
}
// UpdatePolicy updates an existing routing policy.
// PUT /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) UpdatePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
id := c.Param("id")
// Verify exists
if _, err := h.stores.RoutingPolicies.GetByID(context.Background(), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
return
}
var p models.RoutingPolicy
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p.ID = id
if err := h.stores.RoutingPolicies.Update(context.Background(), &p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy: " + err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
// DeletePolicy removes a routing policy.
// DELETE /api/v1/admin/routing/policies/:id
func (h *RoutingAdminHandler) DeletePolicy(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
if err := h.stores.RoutingPolicies.Delete(context.Background(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete policy: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Dry-Run Test ────────────────────────────
type routingTestRequest struct {
Model string `json:"model" binding:"required"`
UserID string `json:"user_id" binding:"required"`
TeamIDs []string `json:"team_ids,omitempty"`
}
// TestRouting performs a dry-run policy evaluation for a given model + user.
// POST /api/v1/admin/routing/test
func (h *RoutingAdminHandler) TestRouting(c *gin.Context) {
if h.stores.RoutingPolicies == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
return
}
var req routingTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load active policies
dbPolicies, err := h.stores.RoutingPolicies.ListActive(context.Background())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load policies"})
return
}
policies := routing.FromModels(dbPolicies)
// Build candidates from all active provider configs
candidates, err := h.buildCandidates(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build candidates: " + err.Error()})
return
}
// Build health status
healthStatus := make(map[string]models.ProviderStatus)
if h.healthStore != nil {
windows, _ := h.healthStore.ListAllCurrent(context.Background())
for _, w := range windows {
healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
}
}
ctx := &routing.Context{
UserID: req.UserID,
TeamIDs: req.TeamIDs,
Model: req.Model,
HealthStatus: healthStatus,
Pricing: map[string]*models.ModelPricing{},
}
result, decision := h.evaluator.Evaluate(ctx, policies, candidates)
c.JSON(http.StatusOK, gin.H{
"candidates": result,
"decision": decision,
"policies": len(policies),
})
}
// buildCandidates creates routing candidates from all active provider configs.
func (h *RoutingAdminHandler) buildCandidates(c *gin.Context) ([]routing.Candidate, error) {
if h.stores.Providers == nil {
return nil, nil
}
// For dry-run, list all global configs (admin scope).
configs, err := h.stores.Providers.ListGlobal(context.Background())
if err != nil {
return nil, err
}
candidates := make([]routing.Candidate, 0, len(configs))
for _, cfg := range configs {
if !cfg.IsActive {
continue
}
candidates = append(candidates, routing.Candidate{
ConfigID: cfg.ID,
ProviderID: cfg.Provider,
Model: "", // model is applied per-policy or from request
Endpoint: cfg.Endpoint,
})
}
return candidates, nil
}

View File

@@ -1,141 +0,0 @@
package handlers
import (
"context"
"log"
"strings"
"switchboard-core/config"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
)
// Known provider default endpoints for seeding.
var seedDefaultEndpoints = map[string]string{
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"openrouter": "https://openrouter.ai/api/v1",
"venice": "https://api.venice.ai/api/v1",
"mistral": "https://api.mistral.ai/v1",
"groq": "https://api.groq.com/openai/v1",
"together": "https://api.together.xyz/v1",
"fireworks": "https://api.fireworks.ai/inference/v1",
"deepseek": "https://api.deepseek.com/v1",
"perplexity": "https://api.perplexity.ai",
}
// SeedProviders creates global providers from the SEED_PROVIDERS env var.
//
// Format: "provider:api_key[:name],provider:api_key[:name]"
//
// Examples:
//
// SEED_PROVIDERS="openai:sk-xxx,anthropic:sk-ant-xxx"
// SEED_PROVIDERS="openai:sk-xxx:My OpenAI,openrouter:sk-or-xxx"
//
// Idempotent: skips if a global provider with the same name already exists.
// Blocked in production.
func SeedProviders(cfg *config.Config, stores store.Stores, resolver *crypto.KeyResolver) {
if cfg.SeedProviders == "" {
return
}
if cfg.Environment == "production" {
log.Printf("⚠ SEED_PROVIDERS ignored in production environment")
return
}
ctx := context.Background()
entries := strings.Split(cfg.SeedProviders, ",")
log.Printf(" 🌱 SEED_PROVIDERS: %d entries to process", len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, ":", 3)
if len(parts) < 2 {
log.Printf("⚠ Seed provider skipped (bad format, want provider:key[:name]): %q", entry)
continue
}
provider := strings.ToLower(strings.TrimSpace(parts[0]))
apiKey := strings.TrimSpace(parts[1])
// Derive name: explicit or "OpenAI (seed)", "Anthropic (seed)", etc.
name := ""
if len(parts) >= 3 {
name = strings.TrimSpace(parts[2])
}
if name == "" {
// Capitalize provider name
name = strings.ToUpper(provider[:1]) + provider[1:] + " (seed)"
}
// Resolve endpoint
endpoint, ok := seedDefaultEndpoints[provider]
if !ok {
// Unknown provider — treat as openai-compatible with custom endpoint
log.Printf("⚠ Seed provider '%s': unknown provider, skipping (no default endpoint)", provider)
continue
}
if apiKey == "" {
log.Printf("⚠ Seed provider '%s': empty API key, skipping", name)
continue
}
// Check if already exists (idempotent)
existing, _ := stores.Providers.ListGlobal(ctx)
found := false
for _, p := range existing {
if p.Name == name || (p.Provider == provider && p.Scope == models.ScopeGlobal) {
found = true
break
}
}
if found {
log.Printf(" 🌱 Seed provider '%s' already exists, skipping", name)
continue
}
// Create provider config
pcfg := &models.ProviderConfig{
Name: name,
Provider: provider,
Endpoint: endpoint,
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
}
// Encrypt API key
if resolver != nil {
enc, nonce, err := resolver.EncryptForScope(apiKey, "global", "")
if err != nil {
log.Printf("⚠ Seed provider '%s': encrypt failed: %v", name, err)
continue
}
pcfg.APIKeyEnc = enc
pcfg.KeyNonce = nonce
} else {
pcfg.APIKeyEnc = []byte(apiKey)
}
if err := stores.Providers.Create(ctx, pcfg); err != nil {
log.Printf("⚠ Seed provider '%s': create failed: %v", name, err)
continue
}
// Auto-fetch and enable models
result, err := syncAndEnableProviderModels(ctx, stores, pcfg, apiKey)
if err != nil {
log.Printf(" 🌱 Seed provider '%s' created (model fetch failed: %v)", name, err)
} else {
log.Printf(" 🌱 Seed provider '%s' created (%d models fetched)", name, result.Total)
}
}
}

View File

@@ -1,203 +0,0 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/events"
"switchboard-core/metrics"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/tools"
)
// recordHealthFn records provider health from standalone streaming functions
// and updates Prometheus completion metrics (v0.33.0).
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
duration := time.Since(start)
latencyMs := int(duration.Milliseconds())
if configID != "" {
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
}
if hr == nil || configID == "" {
return
}
if err != nil {
errMsg := err.Error()
status := "error"
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
hr.RecordRateLimit(configID, latencyMs, errMsg)
status = "rate_limited"
} else {
hr.RecordError(configID, latencyMs, errMsg)
}
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
} else {
hr.RecordSuccess(configID, latencyMs)
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
}
}
// streamWithToolLoop is the SSE streaming entry point for single-model
// completion with tool execution. Sets SSE headers, delegates to
// CoreToolLoop with an sseSink, and returns the accumulated result.
//
// Used by:
// - streamCompletion (normal chat — persists via persistMessage)
// - Regenerate (regen/edit — persists as sibling with tree metadata)
func streamWithToolLoop(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
sink := newSSESink(c, model)
return CoreToolLoop(c.Request.Context(), LoopConfig{
Provider: provider,
Cfg: cfg,
Req: req,
Model: model,
ProviderType: providerType,
ExecCtx: tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
TeamID: teamID,
},
Hub: hub,
Health: health,
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}
const browserToolTimeout = 30 * time.Second
// streamModelResponse is the multi-model streaming variant. Runs on an
// already-established SSE connection — does NOT set SSE headers or send
// [DONE]. SSE deltas include a "model_display" field for frontend attribution.
func streamModelResponse(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
sink := newSSEModelSink(c, model, displayName)
return CoreToolLoop(c.Request.Context(), LoopConfig{
Provider: provider,
Cfg: cfg,
Req: req,
Model: model,
ProviderType: providerType,
ExecCtx: tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
PersonaID: personaID,
WorkspaceID: workspaceID,
TeamID: teamID,
},
Hub: hub,
Health: health,
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
b := make([]byte, 16)
rand.Read(b)
callID := hex.EncodeToString(b)
// Publish tool.call event to the user's browser
payload, _ := json.Marshal(map[string]interface{}{
"call_id": callID,
"tool": call.Name,
"arguments": json.RawMessage(call.Arguments),
})
hub.PublishToUser(userID, events.Event{
Label: "tool.call." + callID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
// Block until browser sends tool.result.{callID} or timeout
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
if !ok {
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
IsError: true,
}
}
// Parse result from the browser event payload
var result struct {
CallID string `json:"call_id"`
Result string `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(event.Payload, &result); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid result from browser tool"}`,
IsError: true,
}
}
if result.Error != "" {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Error,
IsError: true,
}
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Result,
}
}

View File

@@ -1,93 +0,0 @@
package handlers
// summarize.go — Manual conversation summarization via HTTP.
//
// v0.29.0: Raw SQL replaced with ChannelStore methods.
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/compaction"
"switchboard-core/roles"
"switchboard-core/store"
)
// SummarizeHandler handles manual conversation summarization via HTTP.
type SummarizeHandler struct {
stores store.Stores
compaction *compaction.Service
}
// NewSummarizeHandler creates a new handler backed by the compaction service.
func NewSummarizeHandler(stores store.Stores, svc *compaction.Service) *SummarizeHandler {
return &SummarizeHandler{stores: stores, compaction: svc}
}
// ── Summarize & Continue ──────────────────
// POST /channels/:id/summarize
func (h *SummarizeHandler) Summarize(c *gin.Context) {
channelID := c.Param("id")
userID := getUserID(c)
// ── Verify channel ownership ──
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil || ch == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
if ch.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
return
}
// ── Check utility role is configured ──
resolver := h.compaction.Resolver()
if !resolver.IsConfigured(c.Request.Context(), roles.RoleUtility) {
if !resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility) {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Utility model role is not configured. Ask your admin to set one, or configure your own in Settings → Model Roles.",
})
return
}
}
// ── Rate limiting (org-funded calls only) ──
isPersonal := resolver.IsPersonalOverride(c.Request.Context(), userID, roles.RoleUtility)
if !isPersonal {
if err := h.compaction.CheckRateLimit(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
}
// ── Compact ──
teamID := h.compaction.GetUserTeamID(c.Request.Context(), userID)
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
ChannelID: channelID,
UserID: userID,
TeamID: teamID,
Trigger: "manual",
})
if err != nil {
switch err.Error() {
case "conversation too short to summarize":
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case "not enough new messages since last summary":
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{
"summary_id": result.SummaryID,
"summarized_count": result.SummarizedCount,
"model": result.Model,
"used_fallback": result.UsedFallback,
"content": result.Content,
})
}

View File

@@ -1,373 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
capspkg "switchboard-core/capabilities"
"switchboard-core/models"
"switchboard-core/providers"
"switchboard-core/store"
)
// ── Team Provider Handlers ──────────────────
// ListTeamProviders returns API configs scoped to a team.
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
teamID := getTeamID(c)
ctx := c.Request.Context()
configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"})
return
}
type teamProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault *string `json:"model_default"`
Config map[string]interface{} `json:"config"`
IsActive bool `json:"is_active"`
IsPrivate bool `json:"is_private"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
result := make([]teamProvider, 0, len(configs))
for _, cfg := range configs {
var md *string
if cfg.ModelDefault != "" {
md = &cfg.ModelDefault
}
cfgMap := map[string]interface{}{}
if cfg.Config != nil {
cfgMap = cfg.Config
}
result = append(result, teamProvider{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: md,
Config: cfgMap,
IsActive: cfg.IsActive,
IsPrivate: cfg.IsPrivate,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
c.JSON(http.StatusOK, gin.H{
"data": result,
"allow_team_providers": isTeamProvidersAllowed(h.stores, teamID),
})
}
// CreateTeamProvider creates an API config scoped to a team.
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
if !isTeamProvidersAllowed(h.stores, teamID) {
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
return
}
var req struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
// Encrypt the API key for team scope
var apiKeyEnc, keyNonce []byte
if req.APIKey != "" {
if h.vault != nil {
var err error
apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
} else {
apiKeyEnc = []byte(req.APIKey)
}
}
headersMap := models.JSONMap{}
if req.Headers != nil {
for k, v := range req.Headers {
headersMap[k] = v
}
}
cfg := &models.ProviderConfig{
Scope: models.ScopeTeam,
OwnerID: &teamID,
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: apiKeyEnc,
KeyNonce: keyNonce,
KeyScope: "team",
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: headersMap,
IsActive: true,
IsPrivate: req.IsPrivate,
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
log.Printf("[WARN] Failed to create team provider: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": cfg.ID})
}
// UpdateTeamProvider updates a team-scoped API config.
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
var req struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify provider belongs to this team
existing, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
// Build patch
patch := models.ProviderConfigPatch{}
fieldCount := 0
if req.Name != nil {
patch.Name = req.Name
fieldCount++
}
if req.Endpoint != nil {
patch.Endpoint = req.Endpoint
fieldCount++
}
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "")
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)
}
fieldCount++
}
if req.ModelDefault != nil {
patch.ModelDefault = req.ModelDefault
fieldCount++
}
if req.IsActive != nil {
patch.IsActive = req.IsActive
fieldCount++
}
if req.IsPrivate != nil {
patch.IsPrivate = req.IsPrivate
fieldCount++
}
if req.Config != nil {
patch.Config = models.JSONMap(req.Config)
fieldCount++
}
if req.Headers != nil {
headersMap := models.JSONMap{}
for k, v := range req.Headers {
headersMap[k] = v
}
patch.Headers = headersMap
fieldCount++
}
if fieldCount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"})
return
}
c.JSON(http.StatusOK, gin.H{"id": providerID, "updated": true})
}
// DeleteTeamProvider removes a team-scoped API config.
func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
return
}
if n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ListTeamProviderModels lists models available from a team provider (live query).
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
ctx := c.Request.Context()
cfg, err := h.stores.Providers.GetByID(ctx, providerID)
if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
if cfg.OwnerID == nil || *cfg.OwnerID != teamID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider, err := providers.Get(cfg.Provider)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"})
return
}
key := ""
if cfg.HasKey() {
if h.vault != nil {
var err error
key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
return
}
} else {
key = string(cfg.APIKeyEnc)
}
}
var customHeaders map[string]string
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
modelList, err := provider.ListModels(ctx, providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
type modelInfo struct {
ID string `json:"id"`
Type string `json:"type"`
Capabilities models.ModelCapabilities `json:"capabilities"`
}
out := make([]modelInfo, 0, len(modelList))
for _, m := range modelList {
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
}
c.JSON(http.StatusOK, gin.H{"data": out, "provider": cfg.Name})
}
// parseJSONBConfig parses a JSONB text string into a map.
func parseJSONBConfig(raw string) map[string]interface{} {
if raw == "" || raw == "{}" || raw == "null" {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]interface{}{}
}
return m
}
// isTeamProvidersAllowed checks if team providers are enabled.
func isTeamProvidersAllowed(stores store.Stores, teamID string) bool {
if stores.GlobalConfig == nil {
return false
}
ctx := context.Background()
// Check global setting
globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers")
if err == nil && globalVal == "false" {
return false
}
// Check team-level setting
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil {
return true // fail open
}
if team.Settings != nil {
if v, ok := team.Settings["allow_team_providers"]; ok {
if bVal, ok := v.(bool); ok {
return bVal
}
}
}
return true
}

View File

@@ -1,110 +0,0 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/providers"
"switchboard-core/roles"
"switchboard-core/store"
)
// TitleHandler generates chat titles using the utility role.
type TitleHandler struct {
stores store.Stores
resolver *roles.Resolver
}
// NewTitleHandler creates a title generation handler.
func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler {
return &TitleHandler{stores: s, resolver: r}
}
// GenerateTitle generates a title for a channel using the utility role.
// POST /channels/:id/generate-title
func (h *TitleHandler) GenerateTitle(c *gin.Context) {
channelID := c.Param("id")
userID := c.GetString("user_id")
teamID := c.GetString("team_id")
// Verify channel access
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
if err != nil || !owns {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// Load first few messages
msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{
Limit: 4, Order: "asc",
})
if err != nil || len(msgs) == 0 {
c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing
return
}
// Build a snippet from the first messages
var snippet strings.Builder
for _, m := range msgs {
content := m.Content
if len(content) > 200 {
content = content[:200] + "…"
}
fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content)
}
// Call utility role
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
var tID *string
if teamID != "" {
tID = &teamID
}
result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{
{
Role: "system",
Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.",
},
{
Role: "user",
Content: "Title this conversation:\n\n" + snippet.String(),
},
})
if err != nil {
// Fallback: truncate first user message
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
title := strings.TrimSpace(result.Content)
// Strip surrounding quotes if the model added them
title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019")
if title == "" {
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
return
}
// Cap at 100 chars
if len(title) > 100 {
title = title[:100]
}
// Update channel
_ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{
"title": title,
})
c.JSON(http.StatusOK, gin.H{"title": title})
}

View File

@@ -1,782 +0,0 @@
// Package handlers — tool_loop.go
//
// v0.27.2: Extracted core tool loop with sink abstraction.
//
// coreToolLoop is the single implementation of multi-round LLM completion
// with tool execution. All callers (SSE streaming, multi-model streaming,
// sync JSON, headless scheduler) provide a LoopSink for I/O and a LoopConfig
// for dependencies. The loop handles provider calls, tool dispatch (server +
// browser bridge), health recording, budget enforcement, and workspace events.
//
// Prior to this extraction, the same logic was duplicated in three places:
// - streamWithToolLoop (SSE streaming)
// - streamModelResponse (multi-model SSE)
// - syncCompletion (non-streaming JSON)
//
// Those functions are now thin wrappers over coreToolLoop.
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"switchboard-core/events"
"switchboard-core/providers"
"switchboard-core/sandbox"
"switchboard-core/store"
"switchboard-core/tools"
)
// ── Types ──────────────────────────────────────
// defaultMaxRounds is the safety limit on tool call iterations.
// Callers can override via LoopBudget.MaxRounds.
const defaultMaxRounds = 10
// LoopResult holds the accumulated output from a completion with tool
// execution. Callers are responsible for persistence.
// FileRef tracks a file created by a tool for auto-linking to the assistant message.
type FileRef struct {
WorkspaceID string `json:"workspace_id"`
Path string `json:"path"`
}
type LoopResult struct {
Content string
ToolActivity []map[string]interface{}
FileRefs []FileRef // v0.37.18: workspace files created by tools
InputTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
ToolCallCount int // total tool calls across all rounds
BudgetExceeded string // "" | "tokens" | "tool_calls" | "max_rounds"
Error error // non-nil if the loop terminated due to a provider error
}
// LoopBudget controls resource limits for the tool loop.
// Zero values mean "use default" for MaxRounds and "unlimited" for the rest.
type LoopBudget struct {
MaxRounds int // max tool-call iterations; 0 = defaultMaxRounds
MaxToolCalls int // total tool calls across all rounds; 0 = unlimited
MaxTokens int // total input+output tokens; 0 = unlimited
}
func (b LoopBudget) maxRounds() int {
if b.MaxRounds > 0 {
return b.MaxRounds
}
return defaultMaxRounds
}
// LoopConfig bundles all dependencies for the core tool loop.
type LoopConfig struct {
Provider providers.Provider
Cfg providers.ProviderConfig
Req *providers.CompletionRequest
Model string
ProviderType string
ExecCtx tools.ExecutionContext
Hub *events.Hub // nil for headless (scheduler)
Health HealthRecorder // nil for headless
ConfigID string
Budget LoopBudget
Streaming bool // true = StreamCompletion, false = ChatCompletion
// v0.29.2: extension tool dispatch (nil = no extension tools)
Runner *sandbox.Runner
ExtTools map[string]*store.PackageRegistration // toolName → package
}
// ── Sink Interface ─────────────────────────────
// LoopSink receives events from the core tool loop for I/O.
// Implementations control what happens with each event: write SSE to
// a gin.Context, accumulate silently, log to stdout, etc.
type LoopSink interface {
// OnDelta receives a text content delta from the LLM.
OnDelta(delta string)
// OnReasoning receives a reasoning/thinking delta from the LLM.
OnReasoning(delta string)
// OnError receives a fatal error (provider failure, stream error).
OnError(err error)
// OnToolUse is called when the LLM requests tool calls.
OnToolUse(calls []providers.ToolCall)
// OnToolResult is called after each tool execution completes.
OnToolResult(result map[string]interface{})
// OnFinish is called when the LLM produces a finish_reason.
OnFinish(reason string)
// OnDone signals end of the entire completion (after finish or budget breach).
OnDone()
// OnMaxRounds is called when the loop hits its iteration limit.
OnMaxRounds()
}
// ── Core Tool Loop ─────────────────────────────
// coreToolLoop is the single, canonical tool-loop implementation.
// It drives multi-round LLM completion with tool execution, budget
// enforcement, and health recording. I/O is delegated to the sink.
//
// The caller is responsible for:
// - Setting SSE headers (if streaming to a client)
// - Calling providers.GetHooks().PreRequest() before entry
// - Persisting the result after return
// - Sending [DONE] or HTTP response after return (via sink.OnDone)
func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResult {
var result LoopResult
maxRounds := lcfg.Budget.maxRounds()
for iteration := 0; iteration < maxRounds; iteration++ {
var iterContent string
var iterReasoning string
var toolCalls []providers.ToolCall
var iterInput, iterOutput, iterCacheCreate, iterCacheRead int
if lcfg.Streaming {
done := runStreamingRound(ctx, lcfg, sink, &result,
&iterContent, &iterReasoning, &toolCalls,
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
if done {
return result
}
} else {
done := runSyncRound(ctx, lcfg, sink, &result,
&iterContent, &iterReasoning, &toolCalls,
&iterInput, &iterOutput, &iterCacheCreate, &iterCacheRead)
if done {
return result
}
}
// ── Tool execution round ────────────────
if len(toolCalls) == 0 {
// Stream/call ended without tool calls or finish — edge case
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
sink.OnDone()
return result
}
// Budget check: tool calls (pre-execution)
result.ToolCallCount += len(toolCalls)
if lcfg.Budget.MaxToolCalls > 0 && result.ToolCallCount > lcfg.Budget.MaxToolCalls {
result.BudgetExceeded = "tool_calls"
result.Content += iterContent
sink.OnFinish("budget_exceeded")
sink.OnDone()
return result
}
sink.OnToolUse(toolCalls)
// Append assistant message (with tool_calls, possibly with text) to conversation
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
lcfg.Req.Messages = append(lcfg.Req.Messages, assistantMsg)
// Execute all tool calls
for _, tc := range toolCalls {
call := tools.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
}
toolResult := executeToolCall(ctx, lcfg, call)
// Notify sink
resultMap := map[string]interface{}{
"tool_call_id": toolResult.ToolCallID,
"name": toolResult.Name,
"content": toolResult.Content,
"is_error": toolResult.IsError,
}
sink.OnToolResult(resultMap)
// Emit workspace.file.changed for live editor updates (v0.21.5)
emitWorkspaceEvent(lcfg, call, toolResult)
// Collect file refs for tool_output auto-save (v0.37.18)
if ref := collectFileRef(lcfg, call, toolResult); ref != nil {
result.FileRefs = append(result.FileRefs, *ref)
}
// Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
"result": toolResult.Content,
"is_error": toolResult.IsError,
})
// Append tool result to conversation for next iteration
lcfg.Req.Messages = append(lcfg.Req.Messages, providers.Message{
Role: "tool",
Content: toolResult.Content,
ToolCallID: toolResult.ToolCallID,
Name: toolResult.Name,
})
}
result.Content += iterContent
// Budget check: tokens (post-round)
if lcfg.Budget.MaxTokens > 0 && (result.InputTokens+result.OutputTokens) > lcfg.Budget.MaxTokens {
result.BudgetExceeded = "tokens"
sink.OnFinish("budget_exceeded")
sink.OnDone()
return result
}
// Loop: send updated messages back to provider
}
// Hit max iterations
result.BudgetExceeded = "max_rounds"
sink.OnMaxRounds()
sink.OnDone()
return result
}
// ── Streaming Round ────────────────────────────
// runStreamingRound executes one provider StreamCompletion call and
// processes the event stream. Returns true if the loop should return
// (normal finish or error), false if tool execution should follow.
func runStreamingRound(
ctx context.Context,
lcfg LoopConfig,
sink LoopSink,
result *LoopResult,
iterContent, iterReasoning *string,
toolCalls *[]providers.ToolCall,
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
) bool {
callStart := time.Now()
ch, err := lcfg.Provider.StreamCompletion(ctx, lcfg.Cfg, *lcfg.Req)
if err != nil {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
result.Error = err
sink.OnError(err)
return true
}
for event := range ch {
// Apply provider-specific post-processing hooks (v0.22.1)
if hooks := providers.GetHooks(lcfg.ProviderType); hooks != nil {
hooks.PostStreamEvent(lcfg.Cfg, &event)
}
if event.Error != nil {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, event.Error)
result.Error = event.Error
sink.OnError(event.Error)
return true
}
// Reasoning deltas
if event.Reasoning != "" {
*iterReasoning += event.Reasoning
sink.OnReasoning(event.Reasoning)
}
// Content deltas
if event.Delta != "" {
*iterContent += event.Delta
sink.OnDelta(event.Delta)
}
if event.Done {
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, nil)
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
*toolCalls = event.ToolCalls
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
return false // continue to tool execution
}
// Normal completion
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
if *iterReasoning != "" {
result.Content += "<think>" + *iterReasoning + "</think>"
}
result.Content += *iterContent
result.InputTokens += event.InputTokens
result.OutputTokens += event.OutputTokens
result.CacheCreationTokens += event.CacheCreationTokens
result.CacheReadTokens += event.CacheReadTokens
sink.OnFinish(finishReason)
sink.OnDone()
return true
}
}
// Channel closed without Done event — shouldn't happen
return false
}
// ── Sync Round ─────────────────────────────────
// runSyncRound executes one provider ChatCompletion call. Returns true
// if the loop should return, false if tool execution should follow.
func runSyncRound(
ctx context.Context,
lcfg LoopConfig,
sink LoopSink,
result *LoopResult,
iterContent, iterReasoning *string,
toolCalls *[]providers.ToolCall,
iterInput, iterOutput, iterCacheCreate, iterCacheRead *int,
) bool {
callStart := time.Now()
resp, err := lcfg.Provider.ChatCompletion(ctx, lcfg.Cfg, *lcfg.Req)
recordHealthFn(lcfg.Health, lcfg.ConfigID, callStart, err)
if err != nil {
result.Error = err
sink.OnError(err)
return true
}
*iterContent = resp.Content
*iterInput = resp.InputTokens
*iterOutput = resp.OutputTokens
*iterCacheCreate = resp.CacheCreationTokens
*iterCacheRead = resp.CacheReadTokens
result.InputTokens += resp.InputTokens
result.OutputTokens += resp.OutputTokens
result.CacheCreationTokens += resp.CacheCreationTokens
result.CacheReadTokens += resp.CacheReadTokens
if resp.FinishReason == "tool_calls" && len(resp.ToolCalls) > 0 {
*toolCalls = resp.ToolCalls
return false // continue to tool execution
}
// Normal completion — deliver full content as single delta
result.Content += resp.Content
sink.OnDelta(resp.Content)
finishReason := resp.FinishReason
if finishReason == "" {
finishReason = "stop"
}
sink.OnFinish(finishReason)
sink.OnDone()
return true
}
// ── Tool Execution ─────────────────────────────
// executeToolCall dispatches a single tool call: server-side first,
// browser bridge fallback, unknown tool error as last resort.
func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall) tools.ToolResult {
toolStart := time.Now()
// Server-side tool
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
result := tools.ExecuteCall(ctx, lcfg.ExecCtx, call)
if lcfg.Health != nil {
toolLatency := int(time.Since(toolStart).Milliseconds())
if result.IsError {
lcfg.Health.RecordToolError(call.Name, toolLatency, result.Content)
} else {
lcfg.Health.RecordToolSuccess(call.Name, toolLatency)
}
}
return result
}
// v0.29.2: Starlark extension tool
if pkg, ok := lcfg.ExtTools[call.Name]; ok && lcfg.Runner != nil {
log.Printf("🔧 Executing tool (extension): %s (call %s, pkg %s)", call.Name, call.ID, pkg.ID)
return executeExtensionTool(ctx, lcfg, pkg, call)
}
// Browser bridge (only available when hub is connected)
if lcfg.Hub != nil && lcfg.Hub.IsConnected(lcfg.ExecCtx.UserID) {
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
return executeBrowserTool(lcfg.Hub, lcfg.ExecCtx.UserID, call)
}
// Unknown tool
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
// executeExtensionTool calls a starlark extension's on_tool_call entry point
// and serializes the return value to a JSON string for the tool result.
func executeExtensionTool(ctx context.Context, lcfg LoopConfig, pkg *store.PackageRegistration, call tools.ToolCall) tools.ToolResult {
// Parse JSON arguments into a Starlark dict.
var rawArgs map[string]interface{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &rawArgs); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid tool arguments JSON"}`,
IsError: true,
}
}
}
// Build the call dict passed to on_tool_call(call).
callDict := starlark.NewDict(3)
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(call.Name))
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String(call.ID))
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(rawArgs))
rc := &sandbox.RunContext{
UserID: lcfg.ExecCtx.UserID,
ChannelID: lcfg.ExecCtx.ChannelID,
}
val, output, err := lcfg.Runner.CallEntryPoint(ctx, pkg, "on_tool_call",
starlark.Tuple{callDict}, nil, rc)
if output != "" {
log.Printf(" 🔧 ext tool %s print: %s", pkg.ID, output)
}
if err != nil {
log.Printf("⚠️ ext tool %s on_tool_call error: %v", pkg.ID, err)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: fmt.Sprintf(`{"error":%q}`, err.Error()),
IsError: true,
}
}
// Serialize the Starlark return value to JSON.
content, jsonErr := json.Marshal(starlarkValueToGo(val))
if jsonErr != nil {
content = []byte(fmt.Sprintf(`{"error":%q}`, jsonErr.Error()))
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: string(content),
}
}
// jsonToStarlark recursively converts a decoded-JSON value (map/slice/scalar)
// to its Starlark equivalent.
func jsonToStarlark(v interface{}) starlark.Value {
if v == nil {
return starlark.None
}
switch val := v.(type) {
case map[string]interface{}:
d := starlark.NewDict(len(val))
for k, v := range val {
_ = d.SetKey(starlark.String(k), jsonToStarlark(v))
}
return d
case []interface{}:
elems := make([]starlark.Value, len(val))
for i, v := range val {
elems[i] = jsonToStarlark(v)
}
return starlark.NewList(elems)
case string:
return starlark.String(val)
case float64:
if val == float64(int64(val)) {
return starlark.MakeInt64(int64(val))
}
return starlark.Float(val)
case bool:
return starlark.Bool(val)
default:
return starlark.String(fmt.Sprintf("%v", val))
}
}
// starlarkValueToGo recursively converts a Starlark value to a Go value
// suitable for json.Marshal.
func starlarkValueToGo(v starlark.Value) interface{} {
if v == nil || v == starlark.None {
return nil
}
switch val := v.(type) {
case starlark.String:
return string(val)
case starlark.Int:
i64, ok := val.Int64()
if ok {
return i64
}
return val.String()
case starlark.Float:
return float64(val)
case starlark.Bool:
return bool(val)
case *starlark.Dict:
m := make(map[string]interface{}, val.Len())
for _, item := range val.Items() {
k, ok := starlark.AsString(item[0])
if !ok {
k = item[0].String()
}
m[k] = starlarkValueToGo(item[1])
}
return m
case *starlark.List:
list := make([]interface{}, val.Len())
for i := 0; i < val.Len(); i++ {
list[i] = starlarkValueToGo(val.Index(i))
}
return list
default:
return v.String()
}
}
// emitWorkspaceEvent sends workspace.file.changed when a write tool succeeds.
func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) {
if lcfg.Hub == nil || result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
return
}
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
return
}
var toolArgs struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
lcfg.Hub.PublishToUser(lcfg.ExecCtx.UserID, events.Event{
Label: "workspace.file.changed",
Payload: events.MustJSON(map[string]string{
"workspace_id": lcfg.ExecCtx.WorkspaceID,
"path": toolArgs.Path,
}),
})
}
}
// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds.
func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef {
if result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
return nil
}
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
return nil
}
var toolArgs struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
return &FileRef{
WorkspaceID: lcfg.ExecCtx.WorkspaceID,
Path: toolArgs.Path,
}
}
return nil
}
// ── Sink Implementations ───────────────────────
// sseSink writes SSE events to a gin.Context writer for interactive streaming.
type sseSink struct {
w http.ResponseWriter
flush func()
model string
}
func newSSESink(c *gin.Context, model string) *sseSink {
flusher, _ := c.Writer.(http.Flusher)
return &sseSink{
w: c.Writer,
model: model,
flush: func() {
if flusher != nil {
flusher.Flush()
}
},
}
}
func (s *sseSink) sendData(data string) {
fmt.Fprintf(s.w, "data: %s\n\n", data)
s.flush()
}
func (s *sseSink) sendEvent(event, data string) {
fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, data)
s.flush()
}
func (s *sseSink) OnDelta(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
delta, s.model))
}
func (s *sseSink) OnReasoning(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`,
delta, s.model))
}
func (s *sseSink) OnError(err error) {
s.sendData(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
}
func (s *sseSink) OnToolUse(calls []providers.ToolCall) {
toolCallsJSON, _ := json.Marshal(calls)
s.sendEvent("tool_use", string(toolCallsJSON))
}
func (s *sseSink) OnToolResult(result map[string]interface{}) {
resultJSON, _ := json.Marshal(result)
s.sendEvent("tool_result", string(resultJSON))
}
func (s *sseSink) OnFinish(reason string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
reason, s.model))
}
func (s *sseSink) OnDone() {
s.sendData("[DONE]")
}
func (s *sseSink) OnMaxRounds() {
log.Printf("⚠️ Tool loop hit max iterations for model %s", s.model)
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
escapeJSON("[Tool execution limit reached]"), s.model))
}
// sseModelSink extends sseSink with model_display attribution for
// multi-model streaming. Does NOT send [DONE] — the multiModelStream
// caller sends it after all models finish.
type sseModelSink struct {
sseSink
displayName string
}
func newSSEModelSink(c *gin.Context, model, displayName string) *sseModelSink {
base := newSSESink(c, model)
return &sseModelSink{
sseSink: *base,
displayName: displayName,
}
}
func (s *sseModelSink) OnDelta(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
delta, s.model, s.displayName))
}
func (s *sseModelSink) OnReasoning(delta string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q,"model_display":%q}`,
delta, s.model, s.displayName))
}
func (s *sseModelSink) OnFinish(reason string) {
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q,"model_display":%q}`,
reason, s.model, s.displayName))
}
// OnDone is a no-op for multi-model — caller sends [DONE] after all models.
func (s *sseModelSink) OnDone() {}
func (s *sseModelSink) OnMaxRounds() {
log.Printf("⚠️ Multi-model tool loop hit max iterations for model %s", s.displayName)
s.sendData(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q,"model_display":%q}`,
escapeJSON("[Tool execution limit reached]"), s.model, s.displayName))
}
// accumSink accumulates silently — used by syncCompletion where the
// caller formats the HTTP response after the loop returns.
type accumSink struct{}
func (accumSink) OnDelta(string) {}
func (accumSink) OnReasoning(string) {}
func (accumSink) OnError(error) {}
func (accumSink) OnToolUse([]providers.ToolCall) {}
func (accumSink) OnToolResult(map[string]interface{}) {}
func (accumSink) OnFinish(string) {}
func (accumSink) OnDone() {}
func (accumSink) OnMaxRounds() {}
// HeadlessSink is used by the task scheduler — accumulates and logs.
// No client connection, no SSE output.
type HeadlessSink struct {
taskID string
}
func NewHeadlessSink(taskID string) *HeadlessSink {
return &HeadlessSink{taskID: taskID}
}
func (s *HeadlessSink) OnDelta(string) {}
func (s *HeadlessSink) OnReasoning(string) {}
func (s *HeadlessSink) OnError(err error) {
log.Printf("[task:%s] Error: %v", s.taskID, err)
}
func (s *HeadlessSink) OnToolUse(calls []providers.ToolCall) {
names := make([]string, len(calls))
for i, tc := range calls {
names[i] = tc.Function.Name
}
log.Printf("[task:%s] Tool calls: %v", s.taskID, names)
}
func (s *HeadlessSink) OnToolResult(result map[string]interface{}) {
name, _ := result["name"].(string)
isErr, _ := result["is_error"].(bool)
if isErr {
log.Printf("[task:%s] Tool %s failed: %v", s.taskID, name, result["content"])
}
}
func (s *HeadlessSink) OnFinish(reason string) {
log.Printf("[task:%s] Finished: %s", s.taskID, reason)
}
func (s *HeadlessSink) OnDone() {}
func (s *HeadlessSink) OnMaxRounds() {
log.Printf("[task:%s] Hit max tool iterations", s.taskID)
}

View File

@@ -1,59 +0,0 @@
package handlers
// This file previously contained all tree traversal logic (path building,
// sibling queries, cursor management). As of v0.15.0, the core logic lives
// in the treepath package; these are package-local aliases so existing
// handler code (messages.go, completion.go) compiles with minimal churn.
//
// New code should import treepath directly.
import (
"switchboard-core/treepath"
)
// ── Type Aliases ────────────────────────────
// Existing handler code references these types by unqualified name.
type PathMessage = treepath.PathMessage
type SiblingInfo = treepath.SiblingInfo
// ── Function Wrappers ───────────────────────
func getActiveLeaf(channelID, userID string) (*string, error) {
return treepath.GetActiveLeaf(channelID, userID)
}
func getActivePath(channelID, userID string) ([]PathMessage, error) {
return treepath.GetActivePath(channelID, userID)
}
func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
return treepath.GetPathToLeaf(channelID, leafID)
}
func getSiblingCount(channelID string, parentID *string) int {
return treepath.GetSiblingCount(channelID, parentID)
}
func getSiblings(messageID string) ([]SiblingInfo, int, error) {
return treepath.GetSiblings(messageID)
}
func findLeafFromMessage(messageID string) (string, error) {
return treepath.FindLeafFromMessage(messageID)
}
func nextSiblingIndex(channelID string, parentID *string) int {
return treepath.NextSiblingIndex(channelID, parentID)
}
func updateCursor(channelID, userID, messageID string) error {
return treepath.UpdateCursor(channelID, userID, messageID)
}
// isSummaryMessage checks if a PathMessage has summary metadata.
// This was previously defined in completion.go; moved here alongside the
// other tree helpers so all callers use the canonical treepath version.
func isSummaryMessage(m *PathMessage) bool {
return treepath.IsSummaryMessage(m)
}

View File

@@ -1,236 +0,0 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
)
// UsageHandler manages usage tracking and pricing endpoints.
type UsageHandler struct {
stores store.Stores
}
// NewUsageHandler creates a usage handler.
func NewUsageHandler(s store.Stores) *UsageHandler {
return &UsageHandler{stores: s}
}
// ── Admin: Aggregated Usage ────────────────
// GET /admin/usage?since=...&until=...&group_by=model|user|day|provider
func (h *UsageHandler) AdminUsage(c *gin.Context) {
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true // Admin views exclude personal BYOK
results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: Per-User Usage ──────────────────
// GET /admin/usage/users/:id
func (h *UsageHandler) AdminUserUsage(c *gin.Context) {
userID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── Admin: Per-Team Usage ──────────────────
// GET /admin/usage/teams/:id
func (h *UsageHandler) AdminTeamUsage(c *gin.Context) {
teamID := c.Param("id")
opts := h.parseQueryOptions(c)
opts.ExcludeBYOK = true
results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
// ── User: Personal Usage ───────────────────
// GET /usage
func (h *UsageHandler) PersonalUsage(c *gin.Context) {
userID := getUserID(c)
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"})
return
}
results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Team Admin: Team Provider Usage ────────
// GET /teams/:teamId/usage
//
// Shows usage against providers owned by this team. Only team admins
// (enforced by RequireTeamAdmin middleware) can see this view.
func (h *UsageHandler) TeamUsage(c *gin.Context) {
teamID := c.Param("teamId")
opts := h.parseQueryOptions(c)
totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"})
return
}
results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"})
return
}
c.JSON(http.StatusOK, gin.H{
"totals": totals,
"results": results,
})
}
// ── Admin: List Pricing ────────────────────
// GET /admin/pricing
func (h *UsageHandler) ListPricing(c *gin.Context) {
entries, err := h.stores.Pricing.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"})
return
}
if entries == nil {
entries = []models.PricingEntry{}
}
c.JSON(http.StatusOK, entries)
}
// ── Admin: Upsert Pricing ──────────────────
// PUT /admin/pricing
func (h *UsageHandler) UpsertPricing(c *gin.Context) {
var req models.PricingEntry
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider is admin-managed (global or team), not personal BYOK
pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
if err != nil || pc == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"})
return
}
if pc.Scope == "personal" {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"})
return
}
userID := getUserID(c)
req.Source = "manual"
req.UpdatedBy = &userID
if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing saved"})
}
// ── Admin: Delete Pricing ──────────────────
// DELETE /admin/pricing/:provider/:model
func (h *UsageHandler) DeletePricing(c *gin.Context) {
providerConfigID := c.Param("provider")
modelID := c.Param("model")
if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"})
}
// ── Helpers ────────────────────────────────
func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions {
opts := store.UsageQueryOptions{
GroupBy: c.DefaultQuery("group_by", "model"),
Limit: 100,
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
opts.Since = &t
}
}
if until := c.Query("until"); until != "" {
if t, err := time.Parse(time.RFC3339, until); err == nil {
opts.Until = &t
}
}
// Convenience shorthand: ?period=7d|30d|90d
if period := c.Query("period"); period != "" && opts.Since == nil {
var dur time.Duration
switch period {
case "7d":
dur = 7 * 24 * time.Hour
case "30d":
dur = 30 * 24 * time.Hour
case "90d":
dur = 90 * 24 * time.Hour
}
if dur > 0 {
t := time.Now().Add(-dur)
opts.Since = &t
}
}
return opts
}

View File

@@ -1,296 +0,0 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"switchboard-core/config"
"switchboard-core/database"
"switchboard-core/middleware"
"switchboard-core/models"
"switchboard-core/store"
postgres "switchboard-core/store/postgres"
sqlite "switchboard-core/store/sqlite"
)
// ── Workspace Test Harness ────────────────
type workspaceHarness struct {
*testHarness
stores store.Stores
userToken string
userID string
}
func setupWorkspaceHarness(t *testing.T) *workspaceHarness {
t.Helper()
database.RequireTestDB(t)
database.TruncateAll(t)
cfg := &config.Config{
JWTSecret: testJWTSecret,
BasePath: "",
}
var stores store.Stores
if database.IsSQLite() {
stores = sqlite.NewStores(database.TestDB)
} else {
stores = postgres.NewStores(database.TestDB)
}
userCache := middleware.NewUserStatusCache()
r := gin.New()
api := r.Group("/api/v1")
protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
// Workspace handler — nil wfs is fine for CRUD tests that don't touch filesystem
wsH := NewWorkspaceHandler(stores, nil)
protected.GET("/workspaces", wsH.List)
protected.GET("/workspaces/:id", wsH.Get)
// Git credentials — nil vault is fine for List (doesn't decrypt)
gitCredH := NewGitCredentialHandler(stores, nil)
protected.GET("/git-credentials", gitCredH.List)
userID := database.SeedTestUser(t, "wsuser", "wsuser@test.com")
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
userToken := makeToken(userID, "wsuser@test.com", "user")
return &workspaceHarness{
testHarness: &testHarness{router: r, t: t},
stores: stores,
userToken: userToken,
userID: userID,
}
}
// seedWorkspace creates a workspace via the store and returns it.
func (h *workspaceHarness) seedWorkspace(name, ownerType, ownerID string) *models.Workspace {
h.t.Helper()
w := &models.Workspace{
Name: name,
OwnerType: ownerType,
OwnerID: ownerID,
Status: models.WorkspaceStatusActive,
RootPath: "workspaces/test-" + name,
}
w.ID = store.NewID()
if err := h.stores.Workspaces.Create(context.Background(), w); err != nil {
h.t.Fatalf("seedWorkspace: %v", err)
}
return w
}
// ── GET /workspaces — envelope ────────────
func TestWorkspaces_List_Empty_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Must have "data" key
data, ok := body["data"]
if !ok {
t.Fatal("GET /workspaces must return {\"data\": [...]}, missing \"data\" key")
}
// data must be an array (not null)
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
func TestWorkspaces_List_WithData_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
// Seed a workspace owned by the user
h.seedWorkspace("test-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data, ok := body["data"].([]interface{})
if !ok {
t.Fatal("data must be an array")
}
if len(data) != 1 {
t.Fatalf("expected 1 workspace, got %d", len(data))
}
// Verify workspace object shape
ws, ok := data[0].(map[string]interface{})
if !ok {
t.Fatal("workspace entry should be an object")
}
for _, key := range []string{"id", "name", "owner_type", "owner_id", "status", "created_at", "updated_at"} {
if _, exists := ws[key]; !exists {
t.Errorf("missing field %q in workspace object", key)
}
}
if ws["name"] != "test-ws" {
t.Errorf("name: got %v, want test-ws", ws["name"])
}
if ws["owner_type"] != "user" {
t.Errorf("owner_type: got %v, want user", ws["owner_type"])
}
}
func TestWorkspaces_List_DoesNotExposeRootPath(t *testing.T) {
h := setupWorkspaceHarness(t)
h.seedWorkspace("secret-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data := body["data"].([]interface{})
ws := data[0].(map[string]interface{})
if _, exists := ws["root_path"]; exists {
t.Error("root_path must never be exposed in API responses")
}
}
func TestWorkspaces_List_IsolatedByUser(t *testing.T) {
h := setupWorkspaceHarness(t)
// Seed workspace for another user
otherID := database.SeedTestUser(t, "other", "other@test.com")
h.seedWorkspace("other-ws", models.WorkspaceOwnerUser, otherID)
// Also seed one for our user
h.seedWorkspace("my-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces", h.userToken, nil)
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
data := body["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("expected 1 workspace (own only), got %d", len(data))
}
ws := data[0].(map[string]interface{})
if ws["name"] != "my-ws" {
t.Errorf("should only see own workspace, got %v", ws["name"])
}
}
// ── GET /workspaces/:id — single object ───
func TestWorkspaces_Get_Shape(t *testing.T) {
h := setupWorkspaceHarness(t)
w := h.seedWorkspace("detail-ws", models.WorkspaceOwnerUser, h.userID)
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Single object — no "data" wrapper
if _, exists := body["data"]; exists {
t.Error("GET /:id should return object directly, not wrapped in 'data'")
}
if body["id"] != w.ID {
t.Errorf("id: got %v, want %s", body["id"], w.ID)
}
if body["name"] != "detail-ws" {
t.Errorf("name: got %v, want detail-ws", body["name"])
}
}
func TestWorkspaces_Get_NotFound(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces/00000000-0000-0000-0000-000000000000", h.userToken, nil)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.Code)
}
}
func TestWorkspaces_Get_ForbiddenForOtherUser(t *testing.T) {
h := setupWorkspaceHarness(t)
otherID := database.SeedTestUser(t, "stranger", "stranger@test.com")
w := h.seedWorkspace("private-ws", models.WorkspaceOwnerUser, otherID)
resp := h.request("GET", "/api/v1/workspaces/"+w.ID, h.userToken, nil)
if resp.Code != http.StatusForbidden {
t.Fatalf("expected 403 for other user's workspace, got %d", resp.Code)
}
}
// ── GET /git-credentials — envelope ───────
func TestGitCredentials_List_Empty_Envelope(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", h.userToken, nil)
if resp.Code != http.StatusOK {
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
}
var body map[string]interface{}
json.Unmarshal(resp.Body.Bytes(), &body)
// Must have "data" key
data, ok := body["data"]
if !ok {
t.Fatal("GET /git-credentials must return {\"data\": [...]}, missing \"data\" key")
}
// data must be an array (not null)
arr, ok := data.([]interface{})
if !ok {
t.Fatalf("data should be an array, got %T", data)
}
if len(arr) != 0 {
t.Errorf("expected empty array, got %d items", len(arr))
}
}
// ── Auth ──────────────────────────────────
func TestWorkspaces_RequiresAuth(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/workspaces", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}
func TestGitCredentials_RequiresAuth(t *testing.T) {
h := setupWorkspaceHarness(t)
resp := h.request("GET", "/api/v1/git-credentials", "", nil)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 without token, got %d", resp.Code)
}
}

View File

@@ -1,589 +0,0 @@
package handlers
import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workspace"
)
// ── Request Types ───────────────────────────
type createWorkspaceRequest struct {
Name string `json:"name" binding:"required,max=200"`
OwnerType string `json:"owner_type" binding:"required,oneof=user project channel team"`
OwnerID string `json:"owner_id" binding:"required"`
MaxBytes *int64 `json:"max_bytes,omitempty"`
}
type updateWorkspaceRequest = models.WorkspacePatch
// ── Handler ─────────────────────────────────
// WorkspaceHandler handles workspace CRUD, file operations, and archive management.
type WorkspaceHandler struct {
stores store.Stores
wfs *workspace.FS
}
// NewWorkspaceHandler creates a new workspace handler.
func NewWorkspaceHandler(s store.Stores, wfs *workspace.FS) *WorkspaceHandler {
return &WorkspaceHandler{stores: s, wfs: wfs}
}
// ── Workspace CRUD ──────────────────────────
func (h *WorkspaceHandler) Create(c *gin.Context) {
var req createWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Authorization: verify the caller owns or can access the owner entity
if !h.canAccessOwner(c, req.OwnerType, req.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
w := &models.Workspace{
OwnerType: req.OwnerType,
OwnerID: req.OwnerID,
Name: req.Name,
MaxBytes: req.MaxBytes,
Status: models.WorkspaceStatusActive,
}
// Pre-generate ID so root_path can be set before DB insert.
// Works for both Postgres (overrides gen_random_uuid()) and SQLite (store.NewID()).
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(c.Request.Context(), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace"})
log.Printf("workspace create: %v", err)
return
}
// Create directory on disk
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace mkdir: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create workspace directory"})
return
}
c.JSON(http.StatusCreated, w)
}
// GetDefault returns the current user's personal workspace, creating it on first access.
// The personal workspace is the first user-owned workspace (by created_at) or a new
// "My Files" workspace if none exists. Idempotent — safe to call on every page load.
func (h *WorkspaceHandler) GetDefault(c *gin.Context) {
userID := getUserID(c)
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID)
if err == nil {
// Enrich with stats
stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID)
if stats != nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
return
}
if err != sql.ErrNoRows {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"})
log.Printf("workspace getDefault: %v", err)
return
}
// Auto-create personal workspace
w = &models.Workspace{
OwnerType: models.WorkspaceOwnerUser,
OwnerID: userID,
Name: "My Files",
Status: models.WorkspaceStatusActive,
}
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(ctx, w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"})
log.Printf("workspace getDefault create: %v", err)
return
}
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace getDefault mkdir: %v", err)
// Workspace record exists but directory failed — still return the workspace.
// Directory will be created on first file upload.
}
c.JSON(http.StatusOK, w)
}
func (h *WorkspaceHandler) Get(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Enrich with stats
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err == nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
}
// List returns all workspaces accessible to the current user.
// Includes user-owned workspaces and those owned by the user's teams.
func (h *WorkspaceHandler) List(c *gin.Context) {
userID := getUserID(c)
ctx := c.Request.Context()
// Fetch user-owned workspaces
userWs, err := h.stores.Workspaces.ListByOwner(ctx, "user", userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
all := make([]models.Workspace, 0, len(userWs))
all = append(all, userWs...)
// Also include team-owned workspaces
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
for _, tid := range teamIDs {
teamWs, err := h.stores.Workspaces.ListByOwner(ctx, "team", tid)
if err == nil {
all = append(all, teamWs...)
}
}
c.JSON(http.StatusOK, gin.H{"data": all})
}
func (h *WorkspaceHandler) Update(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
var req updateWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Workspaces.Update(c.Request.Context(), w.ID, req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update workspace"})
return
}
// Re-fetch to return the updated object
updated, err := h.stores.Workspaces.GetByID(c.Request.Context(), w.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload workspace"})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *WorkspaceHandler) Delete(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Mark as deleting
status := models.WorkspaceStatusDeleting
h.stores.Workspaces.Update(c.Request.Context(), w.ID, models.WorkspacePatch{Status: &status})
// Async cleanup (use background context — request ctx will be cancelled)
go func() {
ctx := context.Background()
if err := h.wfs.Destroy(ctx, w); err != nil {
log.Printf("workspace destroy %s: %v", w.ID, err)
}
h.stores.Workspaces.Delete(ctx, w.ID)
log.Printf("workspace deleted: %s", w.ID)
}()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── File Operations ─────────────────────────
func (h *WorkspaceHandler) ListFiles(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
recursive := c.Query("recursive") == "true"
files, err := h.wfs.ListDir(c.Request.Context(), w, path, recursive)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
if files == nil {
files = []models.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"data": files})
}
func (h *WorkspaceHandler) ReadFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
rc, size, err := h.wfs.ReadFile(c.Request.Context(), w, path)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type for response header
f, _ := h.wfs.Stat(c.Request.Context(), w, path)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
func (h *WorkspaceHandler) WriteFile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
quota := workspace.DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if stats != nil && stats.TotalBytes+c.Request.ContentLength > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
return
}
if err := h.wfs.WriteFile(c.Request.Context(), w, path, c.Request.Body, c.Request.ContentLength); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "path": path})
}
func (h *WorkspaceHandler) DeleteFileHandler(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(c.Request.Context(), w, path, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *WorkspaceHandler) Mkdir(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(c.Request.Context(), w, path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": path})
}
// ── Archive Operations ──────────────────────
func (h *WorkspaceHandler) UploadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
// Accept multipart file upload
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
// Detect format from filename
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (archive extraction needs seekable file for zip)
tmp, err := os.CreateTemp("", "ws-upload-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(c.Request.Context(), w, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"files_extracted": count,
})
}
func (h *WorkspaceHandler) DownloadArchive(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(c.Request.Context(), w, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
contentType := "application/zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
contentType = "application/gzip"
}
filename := fmt.Sprintf("%s.%s", w.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
_ = contentType // c.File sets the content type from the file
}
// ── Reconcile + Stats ───────────────────────
func (h *WorkspaceHandler) Reconcile(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
added, removed, updated, err := h.wfs.Reconcile(c.Request.Context(), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"added": added,
"removed": removed,
"updated": updated,
})
}
func (h *WorkspaceHandler) Stats(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
stats, err := h.stores.Workspaces.GetStats(c.Request.Context(), w.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get stats"})
return
}
c.JSON(http.StatusOK, stats)
}
// IndexStatus returns indexing progress for all files in the workspace (v0.21.2).
func (h *WorkspaceHandler) IndexStatus(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {
return
}
ctx := c.Request.Context()
files, err := h.stores.Workspaces.ListFiles(ctx, w.ID, "", true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return
}
// Aggregate counts by status
counts := map[string]int{
"pending": 0, "indexing": 0, "ready": 0,
"error": 0, "skipped": 0,
}
totalChunks := 0
for _, f := range files {
if f.IsDirectory {
continue
}
status := f.IndexStatus
if status == "" {
status = "pending"
}
counts[status]++
totalChunks += f.ChunkCount
}
c.JSON(http.StatusOK, gin.H{
"workspace_id": w.ID,
"indexing_enabled": w.IndexingEnabled,
"status_counts": counts,
"total_chunks": totalChunks,
})
}
// ── Authorization Helpers ───────────────────
// loadAndAuthorize loads a workspace by :id param and checks user access.
func (h *WorkspaceHandler) loadAndAuthorize(c *gin.Context) (*models.Workspace, bool) {
wsID := c.Param("id")
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByID(ctx, wsID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workspace"})
}
return nil, false
}
if !h.canAccessOwner(c, w.OwnerType, w.OwnerID) {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return nil, false
}
return w, true
}
// canAccessOwner checks if the current user can access the workspace's owner entity.
func (h *WorkspaceHandler) canAccessOwner(c *gin.Context, ownerType, ownerID string) bool {
userID := getUserID(c)
ctx := c.Request.Context()
switch ownerType {
case models.WorkspaceOwnerUser:
return ownerID == userID
case models.WorkspaceOwnerChannel:
// User must own the channel
ch, err := h.stores.Channels.GetByID(ctx, ownerID)
if err != nil {
return false
}
return ch.UserID == userID
case models.WorkspaceOwnerProject:
teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID)
ok, _ := h.stores.Projects.UserCanAccess(ctx, userID, ownerID, teamIDs)
return ok
case models.WorkspaceOwnerTeam:
// User must be a member of the team
_, err := h.stores.Teams.GetMember(ctx, ownerID, userID)
return err == nil
default:
return false
}
}
// ── Helpers ─────────────────────────────────
// detectArchiveFormat returns "zip" or "tar.gz" based on filename, or "" if unsupported.
func detectArchiveFormat(filename string) string {
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".zip") {
return "zip"
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz"
}
return ""
}