package main import ( "log" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/config" "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/tools" // registers built-in tools via init() ) func main() { cfg := config.Load() // Register LLM providers providers.Init() 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) } // Bootstrap admin from env (K8s secret) — upserts on every restart handlers.BootstrapAdmin(cfg) } defer database.Close() r := gin.Default() r.Use(middleware.CORS()) // ── Base path group ────────────────────── // All routes live under cfg.BasePath (e.g. "/dev", "/test", or "") 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 — auth via ?token= query param base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── auth := handlers.NewAuthHandler(cfg) 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(), } // Include registration status for frontend if database.IsConnected() { info["registration_enabled"] = isRegistrationOpen() } 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 (unified: replaces /chats) 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() 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() protected.POST("/chat/completions", comp.Complete) // API Configs apiCfg := handlers.NewAPIConfigHandler() protected.GET("/api-configs", apiCfg.ListConfigs) protected.POST("/api-configs", apiCfg.CreateConfig) protected.GET("/api-configs/:id", apiCfg.GetConfig) protected.PUT("/api-configs/:id", apiCfg.UpdateConfig) protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig) // Models (per-config and aggregate) protected.GET("/api-configs/:id/models", apiCfg.ListModels) protected.GET("/models", apiCfg.ListAllModels) protected.GET("/models/enabled", apiCfg.ListEnabledModels) protected.GET("/models/preferences", handlers.GetModelPreferences) protected.PUT("/models/preferences", handlers.SetModelPreference) protected.POST("/models/preferences/bulk", handlers.BulkSetModelPreferences) // 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) // Model Presets (user) presets := handlers.NewPresetHandler() protected.GET("/presets", presets.ListUserPresets) protected.POST("/presets", presets.CreateUserPreset) protected.PUT("/presets/:id", presets.UpdateUserPreset) protected.DELETE("/presets/:id", presets.DeleteUserPreset) protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) // Notes notes := handlers.NewNoteHandler() protected.GET("/notes", notes.List) // Teams (user: my teams) teams := handlers.NewTeamHandler() protected.GET("/teams/mine", teams.MyTeams) // Team admin self-service (requires team admin role, not sys-admin) 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 presets teamPresets := handlers.NewPresetHandler() teamScoped.GET("/presets", teamPresets.ListTeamPresets) teamScoped.POST("/presets", teamPresets.CreateTeamPreset) teamScoped.DELETE("/presets/:id", teamPresets.DeleteTeamPreset) } 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) // Public global settings (non-admin users can read safe subset) adm := handlers.NewAdminHandler() protected.GET("/settings/public", adm.PublicSettings) } // ── Admin routes ──────────────────────── admin := api.Group("/admin") admin.Use(middleware.Auth(cfg)) admin.Use(middleware.RequireAdmin()) { adm := handlers.NewAdminHandler() // 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 API Configs admin.GET("/configs", adm.ListGlobalConfigs) admin.POST("/configs", adm.CreateGlobalConfig) admin.PUT("/configs/:id", adm.UpdateGlobalConfig) admin.DELETE("/configs/:id", adm.DeleteGlobalConfig) // Model Configs 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) // Model Presets (admin) presetAdm := handlers.NewPresetHandler() admin.GET("/presets", presetAdm.ListAdminPresets) admin.POST("/presets", presetAdm.CreateAdminPreset) admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset) admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset) admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar) admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar) // Teams (admin) teamAdm := handlers.NewTeamHandler() admin.GET("/teams", teamAdm.ListTeams) // Audit log admin.GET("/audit", adm.ListAuditLog) admin.GET("/audit/actions", adm.ListAuditActions) 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) } } 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) } } func isRegistrationOpen() bool { return handlers.IsRegistrationEnabled() }