This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/main.go
2026-03-06 13:26:25 +00:00

973 lines
39 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/compaction"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/extraction"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/health"
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
"git.gobha.me/xcaliber/chat-switchboard/memory"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/pages"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/roles"
"git.gobha.me/xcaliber/chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
sqliteStore "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/tools"
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
"git.gobha.me/xcaliber/chat-switchboard/workspace"
)
func main() {
// ── Subcommand dispatch ──────────────────
// Usage: switchboard vault rekey
if len(os.Args) > 2 && os.Args[1] == "vault" {
runVaultCommand(os.Args[2])
return
}
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println("switchboard", Version)
return
}
// ── Server startup ──────────────────────
cfg := config.Load()
// Register LLM providers
providers.Init()
var stores store.Stores
uekCache := crypto.NewUEKCache()
var keyResolver *crypto.KeyResolver
var objStore storage.ObjectStore
var healthAccum *health.Accumulator
var healthStore health.Store
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
} else {
// Schema check: init if fresh, upgrade if behind, proceed if current
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Vault: enforce encryption key + backfill plaintext keys
if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault check failed: %v", err)
}
if cfg.EncryptionKey != "" {
if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
log.Fatalf("❌ Vault backfill failed: %v", err)
}
}
// Derive env key for key resolver (nil if not set)
var envKey []byte
if cfg.EncryptionKey != "" {
var err error
envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to derive encryption key: %v", err)
}
log.Println(" 🔐 API key encryption active")
}
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Provider health accumulator (v0.22.0)
if database.IsSQLite() {
healthStore = sqliteStore.NewHealthStore()
} else {
healthStore = postgres.NewHealthStore(database.DB)
}
healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop()
// Auto-disable: deactivate providers that are "down" for N consecutive
// hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var.
// Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable.
autoDisableThreshold := 3
if v := cfg.ProviderAutoDisableThreshold; v >= 0 {
autoDisableThreshold = v
}
if autoDisableThreshold > 0 {
if ad, ok := healthStore.(health.AutoDisabler); ok {
healthAccum.SetAutoDisable(ad, autoDisableThreshold)
log.Printf(" 🛡️ Provider auto-disable: %d consecutive down windows", autoDisableThreshold)
}
}
// Background health cleanup: prune windows older than 7 days
go func() {
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if n, err := healthStore.Prune(ctx, time.Now().UTC().Add(-health.PruneAge)); err != nil {
log.Printf("⚠ health prune failed: %v", err)
} else if n > 0 {
log.Printf("🧹 health: pruned %d old windows", n)
}
cancel()
}
}()
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg, stores)
// Seed additional users from env (dev/test only, skipped in production)
handlers.SeedUsers(cfg, stores)
// Seed providers from env (dev/test only, skipped in production)
handlers.SeedProviders(cfg, stores, keyResolver)
// Seed builtin extensions from disk (idempotent, version-aware)
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
// Load search provider config from DB (defaults to DuckDuckGo if not set)
loadSearchConfig(stores)
}
defer database.Close()
// ── File Storage ─────────────────────────
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
// overrides auto-detection. nil objStore = storage features disabled.
var s3Cfg *storage.S3Config
if cfg.S3Bucket != "" {
s3Cfg = &storage.S3Config{
Endpoint: cfg.S3Endpoint,
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
AccessKey: cfg.S3AccessKey,
SecretKey: cfg.S3SecretKey,
Prefix: cfg.S3Prefix,
ForcePathStyle: cfg.S3ForcePathStyle,
}
}
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath, s3Cfg); err != nil {
if cfg.StorageBackend != "" {
// Explicit backend requested but failed — fatal
log.Fatalf("❌ Storage init failed: %v", err)
}
log.Printf("⚠ Storage init failed: %v", err)
} else {
objStore = s
}
handlers.SetStorageConfigured(objStore != nil)
// ── Extraction Queue ────────────────────
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
// Nil if storage is disabled.
var extQueue *extraction.Queue
if objStore != nil && cfg.StoragePath != "" {
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
if err != nil {
log.Printf("⚠ Extraction queue init failed: %v", err)
} else {
extQueue = q
// Recover items stuck in "processing" from previous crash
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
log.Printf("⚠ Extraction recovery failed: %v", err)
} else if recovered > 0 {
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
}
}
}
// ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus()
// Wire Postgres LISTEN/NOTIFY for cross-pod event fan-out (multi-replica).
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
events.StartPGBroadcast(bus)
// ── Workspace FS (v0.21.0) ──────────────
// Provides file operations for workspace storage primitive.
// Nil-safe: handler checks wfs != nil before operating.
var wfs *workspace.FS
if cfg.StoragePath != "" {
wfs = workspace.NewFS(cfg.StoragePath+"/workspaces", stores.Workspaces)
if err := wfs.Init(); err != nil {
log.Printf("⚠ Workspace FS init failed: %v", err)
wfs = nil
} else {
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
}
}
// Register workspace tools (late registration — needs stores + wfs)
if wfs != nil {
tools.RegisterWorkspaceTools(stores, wfs)
}
// Role resolver for model role dispatch (needs stores + vault + bus)
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
// ── Knowledge Base Pipeline ─────────────
// Embedder + ingester for RAG document processing (v0.14.0).
// Nil-safe: handler checks embedder.IsConfigured() before accepting uploads.
kbEmbedder := knowledge.NewEmbedder(roleResolver).WithStores(stores)
kbIngester := knowledge.NewIngester(stores, kbEmbedder, objStore, knowledge.DefaultConcurrency)
defer kbIngester.Wait() // drain in-flight ingestions on shutdown
// Register kb_search tool (late registration — needs stores + embedder)
tools.RegisterKBSearch(stores, kbEmbedder)
// Register note tools (late registration — needs stores + embedder for semantic search)
tools.RegisterNoteTools(stores, kbEmbedder)
// ── Workspace Indexer (v0.21.2) ───────────
// Background indexing pipeline for workspace files.
// Shares the embedder with KB ingestion; uses its own concurrency semaphore.
var wsIndexer *workspace.Indexer
if wfs != nil {
idxSem := make(chan struct{}, cfg.WorkspaceIndexConcurrency)
wsIndexer = workspace.NewIndexer(stores, kbEmbedder, wfs, idxSem, cfg.WorkspaceIndexingEnabled)
wfs.SetIndexer(wsIndexer) // hook into write path
defer wsIndexer.Wait()
if cfg.WorkspaceIndexingEnabled {
log.Printf(" 📑 Workspace indexer enabled (concurrency=%d)", cfg.WorkspaceIndexConcurrency)
} else {
log.Printf(" 📑 Workspace indexer disabled (WORKSPACE_INDEXING_ENABLED=false)")
}
}
// Register workspace_search tool (needs embedder for query embedding)
tools.RegisterWorkspaceSearchTool(stores, kbEmbedder)
// ── Git Integration (v0.21.4) ────────────
// GitOps wraps exec-based git operations with vault credential injection.
var gitOps *workspace.GitOps
if wfs != nil {
gitOps = workspace.NewGitOps(wfs, stores, keyResolver, wsIndexer)
tools.RegisterGitTools(gitOps)
log.Println(" 🔀 Git integration enabled")
}
// Register context recall tools (v0.15.1)
tools.RegisterFileRecall(stores, objStore)
tools.RegisterConversationSearch(stores)
// Memory tools + extraction scanner (v0.18.0)
tools.RegisterMemoryTools(stores, kbEmbedder)
memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder)
memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{})
memScanner.Start()
defer memScanner.Stop()
r := gin.Default()
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
base := r.Group(cfg.BasePath)
// ── WebSocket Hub ─────────────────────────
hub := events.NewHub(bus)
// ── Notification Service (v0.20.0) ───────
var notifSvc *notifications.Service
if stores.Notifications != nil {
notifSvc = notifications.NewService(stores.Notifications, hub).
WithPrefs(stores.NotifPrefs).
WithUsers(stores.Users)
// Load SMTP config from platform settings for email transport (Phase 3)
if stores.GlobalConfig != nil {
if smtpCfg, err := notifications.LoadSMTPConfig(stores.GlobalConfig, keyResolver); err == nil && smtpCfg != nil {
transport := notifications.NewEmailTransport(*smtpCfg)
instanceName := "Chat Switchboard"
if brandCfg, err := stores.GlobalConfig.Get(context.Background(), "branding"); err == nil {
if name, ok := brandCfg["instance_name"].(string); ok && name != "" {
instanceName = name
}
}
notifSvc.WithEmail(transport, instanceName)
log.Println("[notifications] email transport enabled")
}
}
notifSvc.StartCleanup()
notifications.SetDefault(notifSvc)
// Subscribe to role.fallback events → generate notifications for admins
bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores))
defer notifSvc.StopCleanup()
}
// Health check (k8s probes hit this directly)
base.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"database_name": database.Name(),
"schema_version": database.SchemaVersion(),
})
})
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg, stores, uekCache)
authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1")
{
// Health (routable through ingress)
api.GET("/health", func(c *gin.Context) {
info := gin.H{
"status": "ok",
"version": Version,
"schema_version": database.SchemaVersion(),
"database": database.IsConnected(),
"database_name": database.Name(),
"providers": providers.List(),
}
if database.IsConnected() {
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
}
c.JSON(200, info)
})
authGroup := api.Group("/auth")
authGroup.Use(authLimiter.Limit())
{
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/logout", auth.Logout)
}
// ── Public extension assets ────────────────
// Script tags can't send Authorization headers, so asset serving must be public.
// Extension JS is the same for all users — no user-specific data.
extH := handlers.NewExtensionHandler(stores)
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
// ── Protected routes ────────────────────
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Chat Folders (v0.23.1)
folders := handlers.NewFolderHandler()
protected.GET("/folders", folders.List)
protected.POST("/folders", folders.Create)
protected.PUT("/folders/:id", folders.Update)
protected.DELETE("/folders/:id", folders.Delete)
// Presence (v0.23.1)
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
protected.GET("/presence", handlers.PresenceQuery)
// Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List)
protected.POST("/channels/:id/models", chModelH.Add)
protected.PATCH("/channels/:id/models/:modelId", chModelH.Update)
protected.DELETE("/channels/:id/models/:modelId", chModelH.Delete)
// Channel participants (v0.23.0 — ICD §3.7)
partH := handlers.NewParticipantHandler(stores)
protected.GET("/channels/:id/participants", partH.List)
protected.POST("/channels/:id/participants", partH.Add)
protected.PATCH("/channels/:id/participants/:participantId", partH.Update)
protected.DELETE("/channels/:id/participants/:participantId", partH.Remove)
// Messages
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Chat Completions
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
if healthAccum != nil {
comp.SetHealthRecorder(healthAccum)
comp.SetHealthStore(healthStore)
}
comp.SetRoutingEvaluator(routing.NewEvaluator())
protected.POST("/chat/completions", comp.Complete)
protected.GET("/tools", comp.ListTools)
// Summarize & Continue (backed by compaction service)
compactionSvc := compaction.NewService(stores, roleResolver)
summarize := handlers.NewSummarizeHandler(compactionSvc)
protected.POST("/channels/:id/summarize", summarize.Summarize)
// Auto-title generation (utility role)
titleH := handlers.NewTitleHandler(stores, roleResolver)
protected.POST("/channels/:id/generate-title", titleH.GenerateTitle)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig)
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
if healthStore != nil {
modelH.SetHealthStore(healthStore)
}
protected.GET("/models/enabled", modelH.ListEnabledModels)
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler(uekCache)
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.POST("/profile/avatar", settings.UploadAvatar)
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Usage (personal)
usage := handlers.NewUsageHandler(stores)
protected.GET("/usage", usage.PersonalUsage)
// Personas
personas := handlers.NewPersonaHandler(stores)
protected.GET("/personas", personas.ListUserPersonas)
protected.POST("/personas", personas.CreateUserPersona)
protected.PUT("/personas/:id", personas.UpdateUserPersona)
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
// Notes
notes := handlers.NewNoteHandler(stores)
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/search-titles", notes.SearchTitles)
protected.GET("/notes/folders", notes.ListFolders)
protected.GET("/notes/graph", notes.Graph)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
protected.GET("/notes/:id/backlinks", notes.Backlinks)
// Projects (v0.19.0)
projectH := handlers.NewProjectHandler(stores)
protected.GET("/projects", projectH.List)
protected.POST("/projects", projectH.Create)
protected.GET("/projects/:id", projectH.Get)
protected.PUT("/projects/:id", projectH.Update)
protected.DELETE("/projects/:id", projectH.Delete)
protected.POST("/projects/:id/channels", projectH.AddChannel)
protected.DELETE("/projects/:id/channels/:channelId", projectH.RemoveChannel)
protected.GET("/projects/:id/channels", projectH.ListChannels)
protected.PUT("/projects/:id/channels/reorder", projectH.ReorderChannels)
protected.POST("/projects/:id/knowledge-bases", projectH.AddKB)
protected.DELETE("/projects/:id/knowledge-bases/:kbId", projectH.RemoveKB)
protected.GET("/projects/:id/knowledge-bases", projectH.ListKBs)
protected.POST("/projects/:id/notes", projectH.AddNote)
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
protected.GET("/projects/:id/notes", projectH.ListNotes)
// Project files (v0.22.4) — wired via fileH which is declared later,
// so we use a closure that captures the variable.
protected.POST("/projects/:id/files", func(c *gin.Context) {
handlers.NewFileHandler(stores, objStore, extQueue).UploadToProject(c)
})
protected.GET("/projects/:id/files", func(c *gin.Context) {
handlers.NewFileHandler(stores, objStore, extQueue).ListByProject(c)
})
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)
protected.GET("/notifications", notifH.List)
protected.GET("/notifications/unread-count", notifH.UnreadCount)
protected.PATCH("/notifications/:id/read", notifH.MarkRead)
protected.POST("/notifications/mark-all-read", notifH.MarkAllRead)
protected.DELETE("/notifications/:id", notifH.Delete)
// Notification preferences (v0.20.0 Phase 3)
protected.GET("/notifications/preferences", notifH.ListPreferences)
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
// Workspaces (v0.21.0)
if wfs != nil {
wsH := handlers.NewWorkspaceHandler(stores, wfs)
protected.POST("/workspaces", wsH.Create)
protected.GET("/workspaces", wsH.List)
protected.GET("/workspaces/:id", wsH.Get)
protected.PATCH("/workspaces/:id", wsH.Update)
protected.DELETE("/workspaces/:id", wsH.Delete)
protected.GET("/workspaces/:id/files", wsH.ListFiles)
protected.GET("/workspaces/:id/files/read", wsH.ReadFile)
protected.PUT("/workspaces/:id/files/write", wsH.WriteFile)
protected.DELETE("/workspaces/:id/files/delete", wsH.DeleteFileHandler)
protected.POST("/workspaces/:id/files/mkdir", wsH.Mkdir)
protected.POST("/workspaces/:id/archive/upload", wsH.UploadArchive)
protected.GET("/workspaces/:id/archive/download", wsH.DownloadArchive)
protected.POST("/workspaces/:id/reconcile", wsH.Reconcile)
protected.GET("/workspaces/:id/stats", wsH.Stats)
protected.GET("/workspaces/:id/index-status", wsH.IndexStatus)
// Git operations (v0.21.4)
if gitOps != nil {
gitH := handlers.NewGitHandler(stores, gitOps)
protected.POST("/workspaces/:id/git/clone", gitH.Clone)
protected.POST("/workspaces/:id/git/pull", gitH.Pull)
protected.POST("/workspaces/:id/git/push", gitH.Push)
protected.GET("/workspaces/:id/git/status", gitH.Status)
protected.GET("/workspaces/:id/git/diff", gitH.Diff)
protected.POST("/workspaces/:id/git/commit", gitH.Commit)
protected.GET("/workspaces/:id/git/log", gitH.Log)
protected.GET("/workspaces/:id/git/branches", gitH.Branches)
protected.POST("/workspaces/:id/git/checkout", gitH.Checkout)
}
}
// Git credentials (v0.21.4) — user-scoped, independent of workspace
gitCredH := handlers.NewGitCredentialHandler(stores, keyResolver)
protected.POST("/git-credentials", gitCredH.Create)
protected.GET("/git-credentials", gitCredH.List)
protected.DELETE("/git-credentials/:id", gitCredH.Delete)
// Files (upload/download)
fileH := handlers.NewFileHandler(stores, objStore, extQueue)
protected.POST("/channels/:id/files", fileH.Upload)
protected.GET("/channels/:id/files", fileH.ListByChannel)
protected.GET("/files/:id", fileH.GetMetadata)
protected.GET("/files/:id/download", fileH.Download)
protected.DELETE("/files/:id", fileH.Delete)
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
exportH := handlers.NewExportHandler()
protected.POST("/export", exportH.Convert)
// Hook: clean up storage files when channels are deleted
handlers.SetChannelDeleteHook(fileH.CleanupChannelStorage)
// Knowledge Bases (RAG — v0.14.0)
kbH := handlers.NewKnowledgeBaseHandler(stores, objStore, kbIngester, kbEmbedder)
protected.POST("/knowledge-bases", kbH.CreateKB)
protected.GET("/knowledge-bases", kbH.ListKBs)
protected.GET("/knowledge-bases/:id", kbH.GetKB)
protected.PUT("/knowledge-bases/:id", kbH.UpdateKB)
protected.DELETE("/knowledge-bases/:id", kbH.DeleteKB)
protected.POST("/knowledge-bases/:id/documents", kbH.UploadDocument)
protected.GET("/knowledge-bases/:id/documents", kbH.ListDocuments)
protected.GET("/knowledge-bases/:id/documents/:docId/status", kbH.GetDocumentStatus)
protected.DELETE("/knowledge-bases/:id/documents/:docId", kbH.DeleteDocument)
protected.POST("/knowledge-bases/:id/search", kbH.SearchKB)
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 (admin only enforced in handler)
// Memory management (v0.18.0)
memH := handlers.NewMemoryHandler(stores)
protected.GET("/memories", memH.ListMyMemories)
protected.PUT("/memories/:id", memH.UpdateMemory)
protected.DELETE("/memories/:id", memH.DeleteMemory)
protected.POST("/memories/:id/approve", memH.ApproveMemory)
protected.POST("/memories/:id/reject", memH.RejectMemory)
protected.GET("/memories/count", memH.MemoryCount)
// Teams (user: my teams)
teams := handlers.NewTeamHandler(keyResolver)
protected.GET("/teams/mine", teams.MyTeams)
// Groups (user: my groups — v0.16.0)
groupH := handlers.NewGroupHandler(stores)
protected.GET("/groups/mine", groupH.MyGroups)
// Team admin self-service
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
{
teamScoped.GET("/members", teams.ListMembers)
teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/models", teams.ListAvailableModels)
// Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups)
// Team providers
teamScoped.GET("/providers", teams.ListTeamProviders)
teamScoped.POST("/providers", teams.CreateTeamProvider)
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team audit log (team admins only — RequireTeamAdmin on group)
teamScoped.GET("/audit", teams.ListTeamAuditLog)
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
// Team usage (team admins only — usage against team-owned providers)
teamUsage := handlers.NewUsageHandler(stores)
teamScoped.GET("/usage", teamUsage.TeamUsage)
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
// Team role overrides
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
}
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
protected.GET("/settings/public", adm.PublicSettings)
// Extensions (user-facing)
protected.GET("/extensions", extH.ListUserExtensions)
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
}
// ── Admin routes ────────────────────────
admin := api.Group("/admin")
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
// User management
admin.GET("/users", adm.ListUsers)
admin.POST("/users", adm.CreateUser)
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.POST("/users/:id/vault/reset", adm.ResetVault)
admin.DELETE("/users/:id", adm.DeleteUser)
// Global settings
admin.GET("/settings", adm.ListGlobalSettings)
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
// Stats
admin.GET("/stats", adm.GetStats)
// Global Provider Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/personas", personaAdm.ListAdminPersonas)
admin.POST("/personas", personaAdm.CreateAdminPersona)
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
admin.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
// Admin memory review (v0.18.0)
adminMemH := handlers.NewMemoryHandler(stores)
admin.GET("/memories/pending", adminMemH.ListPendingReview)
admin.POST("/memories/bulk-approve", adminMemH.BulkApprove)
// Teams (admin)
teamAdm := handlers.NewTeamHandler(keyResolver)
admin.GET("/teams", teamAdm.ListTeams)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
admin.GET("/teams/:id/members", teamAdm.ListMembers)
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
// Groups (admin — v0.16.0)
groupAdm := handlers.NewGroupHandler(stores)
admin.GET("/groups", groupAdm.ListGroups)
admin.POST("/groups", groupAdm.CreateGroup)
admin.GET("/groups/:id", groupAdm.GetGroup)
admin.PUT("/groups/:id", groupAdm.UpdateGroup)
admin.DELETE("/groups/:id", groupAdm.DeleteGroup)
admin.GET("/groups/:id/members", groupAdm.ListMembers)
admin.POST("/groups/:id/members", groupAdm.AddMember)
admin.DELETE("/groups/:id/members/:userId", groupAdm.RemoveMember)
// Resource Grants (admin — v0.16.0)
admin.GET("/grants/:type/:id", groupAdm.GetResourceGrant)
admin.PUT("/grants/:type/:id", groupAdm.SetResourceGrant)
// Projects (admin — v0.19.0)
adminProjH := handlers.NewProjectHandler(stores)
admin.GET("/projects", adminProjH.AdminList)
admin.DELETE("/projects/:id", adminProjH.Delete)
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
// Model Roles
rolesH := handlers.NewRolesHandler(stores, roleResolver)
admin.GET("/roles", rolesH.ListRoles)
admin.GET("/roles/:role", rolesH.GetRole)
admin.PUT("/roles/:role", rolesH.UpdateRole)
admin.POST("/roles/:role/test", rolesH.TestRole)
// Usage & Pricing
usageH := handlers.NewUsageHandler(stores)
admin.GET("/usage", usageH.AdminUsage)
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
admin.GET("/pricing", usageH.ListPricing)
admin.PUT("/pricing", usageH.UpsertPricing)
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
// Storage status
storageH := handlers.NewStorageHandler(objStore)
admin.GET("/storage/status", storageH.Status)
// Vault
admin.GET("/vault/status", adm.VaultStatus)
// Email / SMTP test (v0.20.0 Phase 3)
emailAdm := handlers.NewAdminEmailHandler(stores)
admin.POST("/notifications/test-email", emailAdm.TestEmail)
// Storage management (orphan cleanup)
fileAdm := handlers.NewFileHandler(stores, objStore, extQueue)
admin.GET("/storage/orphans", fileAdm.OrphanCount)
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
// Extensions (admin)
extAdm := handlers.NewExtensionHandler(stores)
admin.GET("/extensions", extAdm.AdminListExtensions)
admin.POST("/extensions", extAdm.AdminInstallExtension)
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
// Provider Health (admin — v0.22.0)
healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
// Capability Overrides (admin — v0.22.0)
capAdm := handlers.NewCapOverrideAdminHandler(stores)
admin.GET("/models/:id/capabilities", capAdm.GetModelCapabilities)
admin.PUT("/models/:id/capabilities", capAdm.SetModelCapability)
admin.DELETE("/models/:id/capabilities/:overrideId", capAdm.DeleteModelCapability)
admin.GET("/capability-overrides", capAdm.ListAllOverrides)
// Provider Types (admin — v0.22.1)
admin.GET("/provider-types", handlers.GetProviderTypes)
// Routing Policies (admin — v0.22.2)
routingEval := routing.NewEvaluator()
routingAdm := handlers.NewRoutingAdminHandler(stores, routingEval, healthStore)
admin.GET("/routing/policies", routingAdm.ListPolicies)
admin.GET("/routing/policies/:id", routingAdm.GetPolicy)
admin.POST("/routing/policies", routingAdm.CreatePolicy)
admin.PUT("/routing/policies/:id", routingAdm.UpdatePolicy)
admin.DELETE("/routing/policies/:id", routingAdm.DeletePolicy)
admin.POST("/routing/test", routingAdm.TestRouting)
}
}
// ── Page Routes (v0.22.5) ────────────────
// Server-rendered pages via Go templates. Runs alongside the SPA.
// The chat surface is a bridge: Go renders the shell, existing JS
// builds the DOM inside it — identical behavior to index.html.
pages.SetVersion(Version)
pageEngine := pages.New(cfg, stores)
// Login page — no auth required
base.GET("/login", pageEngine.RenderLogin())
// Authenticated page routes
pageRoutes := base.Group("")
pageRoutes.Use(middleware.AuthOrRedirect(cfg))
{
// Chat surface (default) — bridge to existing SPA
pageRoutes.GET("/", pageEngine.RenderSurface("chat"))
pageRoutes.GET("/chat/:chatID", pageEngine.RenderSurface("chat"))
// Editor surface — server renders layout, JS builds CodeMirror inside
pageRoutes.GET("/editor", pageEngine.RenderSurface("editor"))
pageRoutes.GET("/editor/:wsId", pageEngine.RenderSurface("editor"))
// Notes surface
pageRoutes.GET("/notes", pageEngine.RenderSurface("notes"))
pageRoutes.GET("/notes/:noteId", pageEngine.RenderSurface("notes"))
}
// Admin pages — auth + admin role required
adminPages := base.Group("/admin")
adminPages.Use(middleware.AuthOrRedirect(cfg))
adminPages.Use(middleware.RequireAdminPage())
{
adminPages.GET("", pageEngine.RenderSurface("admin"))
adminPages.GET("/:section", pageEngine.RenderSurface("admin"))
}
// Settings pages — auth required
settingsPages := base.Group("/settings")
settingsPages.Use(middleware.AuthOrRedirect(cfg))
{
settingsPages.GET("", pageEngine.RenderSurface("settings"))
settingsPages.GET("/:section", pageEngine.RenderSurface("settings"))
}
bp := cfg.BasePath
if bp == "" {
bp = "/"
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Base path: %s", bp)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
if objStore != nil {
log.Printf(" Storage: %s", objStore.Backend())
if extQueue != nil {
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
} else {
log.Printf(" Extraction: disabled")
}
} else {
log.Printf(" Storage: disabled")
}
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
log.Printf(" Health: provider tracking active (flush=%s, prune=%s)", health.FlushInterval, health.PruneAge)
log.Printf(" Pages: template engine active (surfaces: chat, editor, notes, settings, admin)")
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
// ── Vault CLI Commands ──────────────────────
// loadSearchConfig reads search provider config from global_config and applies it.
// Falls back to DuckDuckGo (the default set in search package init) if not configured.
func loadSearchConfig(stores store.Stores) {
raw, err := stores.GlobalConfig.Get(context.Background(), "search_config")
if err != nil || raw == nil {
log.Println("🔍 Search: using default provider (DuckDuckGo)")
return
}
b, _ := json.Marshal(raw)
var cfg search.Config
if err := json.Unmarshal(b, &cfg); err != nil {
log.Printf("⚠️ Failed to parse search config: %v", err)
return
}
if err := search.ApplyConfig(cfg); err != nil {
log.Printf("⚠️ Failed to apply search config: %v", err)
}
}
func runVaultCommand(subcmd string) {
cfg := config.Load()
switch strings.ToLower(subcmd) {
case "rekey":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
oldKey := os.Getenv("ENCRYPTION_KEY")
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
log.Fatalf("❌ Vault rekey failed: %v", err)
}
case "status":
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required for vault operations")
}
if err := database.Connect(cfg); err != nil {
log.Fatalf("❌ Database connection failed: %v", err)
}
defer database.Close()
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
if err != nil {
log.Fatalf("❌ Failed to get vault status: %v", err)
}
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
default:
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
os.Exit(1)
}
}