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-02-22 16:52:19 +00:00

278 lines
10 KiB
Go

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()
}