package main import ( "log" "github.com/gin-gonic/gin" "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/handlers" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/store" postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres" _ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init() ) func main() { cfg := config.Load() // Register LLM providers providers.Init() var stores store.Stores uekCache := crypto.NewUEKCache() var keyResolver *crypto.KeyResolver 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) // 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) } defer database.Close() r := gin.Default() r.Use(middleware.CORS()) // ── Base path group ────────────────────── base := r.Group(cfg.BasePath) // ── EventBus + WebSocket Hub ───────────── bus := events.NewBus() hub := events.NewHub(bus) // 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(), "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(), "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) } // ── 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) // Messages msgs := handlers.NewMessageHandler(keyResolver) 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) protected.POST("/chat/completions", comp.Complete) // 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) protected.GET("/models/enabled", modelH.ListEnabledModels) protected.GET("/models", modelH.ListEnabledModels) // alias // 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() 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) // Personas (replaces /presets) personas := handlers.NewPersonaHandler(stores) protected.GET("/presets", personas.ListUserPersonas) // backward compat protected.POST("/presets", personas.CreateUserPersona) protected.PUT("/presets/:id", personas.UpdateUserPersona) protected.DELETE("/presets/:id", personas.DeleteUserPersona) protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) // Notes notes := handlers.NewNoteHandler() protected.GET("/notes", notes.List) protected.POST("/notes", notes.Create) protected.GET("/notes/search", notes.Search) protected.GET("/notes/folders", notes.ListFolders) 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) // Teams (user: my teams) teams := handlers.NewTeamHandler(keyResolver) protected.GET("/teams/mine", teams.MyTeams) // 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 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 (scoped to team members) teamScoped.GET("/audit", teams.ListTeamAuditLog) teamScoped.GET("/audit/actions", teams.ListTeamAuditActions) // Team personas teamPersonas := handlers.NewPersonaHandler(stores) teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) teamScoped.POST("/presets", teamPersonas.CreateTeamPersona) teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona) } // Public global settings (non-admin users can read safe subset) adm := handlers.NewAdminHandler(stores, keyResolver) protected.GET("/settings/public", adm.PublicSettings) } // ── Admin routes ──────────────────────── admin := api.Group("/admin") admin.Use(middleware.Auth(cfg)) admin.Use(middleware.RequireAdmin()) { adm := handlers.NewAdminHandler(stores, keyResolver) // 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.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("/presets", personaAdm.ListAdminPersonas) admin.POST("/presets", personaAdm.CreateAdminPersona) admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona) admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona) admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) // 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) } } 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()) log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("Failed to start server: %v", err) } }