package main import ( "bytes" "context" _ "embed" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "chat-switchboard/auth" "chat-switchboard/compaction" "chat-switchboard/config" "chat-switchboard/crypto" "chat-switchboard/database" "chat-switchboard/events" "chat-switchboard/extraction" "chat-switchboard/filters" "chat-switchboard/sandbox" "chat-switchboard/handlers" "chat-switchboard/health" "chat-switchboard/logging" "chat-switchboard/metrics" "chat-switchboard/knowledge" "chat-switchboard/memory" "chat-switchboard/middleware" "chat-switchboard/notifications" "chat-switchboard/pages" "chat-switchboard/providers" "chat-switchboard/retention" "chat-switchboard/roles" "chat-switchboard/routing" "chat-switchboard/scheduler" "chat-switchboard/storage" "chat-switchboard/store" postgres "chat-switchboard/store/postgres" sqliteStore "chat-switchboard/store/sqlite" "chat-switchboard/tools" "chat-switchboard/tools/search" "chat-switchboard/treepath" "chat-switchboard/workspace" ) // v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs. // //go:embed static/openapi.yaml var openapiSpec []byte //go:embed static/swagger.html var swaggerHTML []byte 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() // v0.33.0: Structured logging — must be first so all subsequent // log output goes through slog. logging.Init(cfg.LogFormat, cfg.LogLevel) // Register LLM providers providers.Init() // v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.) if cfg.ProviderTypesFile != "" { n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile) if err != nil { log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err) } else if n > 0 { log.Printf(" 📦 Loaded %d custom provider type(s) from %s", n, cfg.ProviderTypesFile) } } 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 if database.IsSQLite() { stores = sqliteStore.NewStores(database.DB) } else { stores = postgres.NewStores(database.DB) } // v0.29.0: Wire store layer into treepath for backward compat. // New code should call stores.Messages.* directly. treepath.Stores = &stores // 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() // v0.33.0: Start Prometheus DB pool collector metrics.StartDBCollector(database.DB, 15*time.Second) // 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() } }() // Background session cleanup (v0.26.0): remove expired anonymous sessions if cfg.SessionExpiryDays > 0 { go func() { ticker := time.NewTicker(6 * time.Hour) defer ticker.Stop() for { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) cutoff := time.Now().UTC().AddDate(0, 0, -cfg.SessionExpiryDays) if n, err := stores.Sessions.DeleteExpired(ctx, cutoff); err != nil { log.Printf("⚠ session cleanup failed: %v", err) } else if n > 0 { log.Printf("🧹 sessions: cleaned up %d expired sessions", n) } cancel() <-ticker.C } }() } // Background workflow staleness sweep (v0.26.2): mark idle instances as stale // + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy if cfg.WorkflowStaleHours > 0 { go func() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Staleness: mark idle active instances as stale cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour) n, err := stores.Channels.MarkStaleWorkflows(ctx, cutoff) if err != nil { log.Printf("⚠ workflow staleness sweep failed: %v", err) } else if n > 0 { log.Printf("🧹 workflows: marked %d instances as stale", n) } // v0.27.0: Retention enforcement — delete completed workflow channels // where the parent workflow has retention.mode="delete" and // retention.delete_after_days has elapsed since completion. n2, err := stores.Channels.EnforceWorkflowRetention(ctx) if err != nil { // SQLite doesn't support JSON operators — skip retention on SQLite if !database.IsSQLite() { log.Printf("⚠ workflow retention enforcement failed: %v", err) } } else if n2 > 0 { log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n2) } cancel() <-ticker.C } }() } // v0.37.14: Channel retention scanner — purges archived channels past their TTL retScanner := retention.NewScanner(stores, objStore, retention.ScannerConfig{}) retScanner.Start() defer retScanner.Stop() // v0.27.2: Task scheduler startup deferred to after hub/notification init — see below. // Bootstrap admin from env (K8s secret) — upserts on every restart handlers.BootstrapAdmin(cfg, stores, uekCache) // Seed additional users from env (dev/test only, skipped in production) handlers.SeedUsers(cfg, stores, uekCache) // Seed providers from env (dev/test only, skipped in production) handlers.SeedProviders(cfg, stores, keyResolver) // Seed builtin extensions from disk (idempotent, version-aware) handlers.SeedBuiltinPackages(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) // Workflow tools (v0.26.4 — AI-triggered stage advancement) tools.RegisterWorkflowTools(stores) // v0.27.3: task_create tool (AI spawns sub-tasks) tools.RegisterTaskTools(stores) memExtractor := memory.NewExtractor(stores, roleResolver, kbEmbedder) memCompactor := memory.NewCompactor(stores, roleResolver) memScanner := memory.NewScanner(memExtractor, stores, memory.ScannerConfig{}) memScanner.Start() defer memScanner.Stop() // ── Pre-completion filter chain (v0.29.0) ── // Built-in filters register here. Starlark extension filters will // register at package install time (CS3). filterChain := filters.NewChain() filterChain.Register(filters.NewKBInjectFilter(stores)) // ── Starlark Runner (v0.29.0 CS3) ── // Sandboxed interpreter for extension scripts. Runner assembles // modules based on granted permissions. Notifier attached below // after notification service init. starlarkRunner := sandbox.NewRunner( sandbox.New(sandbox.DefaultConfig()), stores, ) // v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver)) // v0.38.1: connections module — extension connection resolution starlarkRunner.SetConnectionResolver(handlers.NewConnectionResolverAdapter(stores, keyResolver)) // v0.29.2: db module — extension namespaced table access starlarkRunner.SetDB(database.DB, database.IsPostgres()) // v0.38.0: disk-based script loading + load() support if cfg.StoragePath != "" { starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages") } // v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.) if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" { starlarkRunner.SetAllowPrivateIPs(true) log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed") } // Discover and register active Starlark pre-completion filters filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner) log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len()) r := gin.New() r.Use(middleware.RequestID()) r.Use(middleware.Prometheus()) r.Use(middleware.Logger()) r.Use(middleware.Recovery()) userCache := middleware.NewUserStatusCache() r.Use(middleware.CORS(cfg)) // ── Base path group ────────────────────── base := r.Group(cfg.BasePath) // ── WebSocket Hub ───────────────────────── hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg)) // ── WebSocket Ticket Store (v0.32.0: PG-backed for cross-pod) ───── ticketAdapter := &events.TicketValidatorAdapter{Store: stores.Tickets} // ── 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) starlarkRunner.SetNotifier(notifSvc) // Subscribe to role.fallback events → generate notifications for admins bus.Subscribe("role.fallback", notifications.RoleFallbackHandler(notifSvc, stores)) defer notifSvc.StopCleanup() } // v0.27.2: Task scheduler with executor — needs hub + notification service if stores.Tasks != nil { // v0.28.6: Register built-in system task functions scheduler.RegisterBuiltins() exec := scheduler.NewExecutor(stores, keyResolver, hub, healthAccum) exec.SetRunner(starlarkRunner) taskSched := scheduler.New(stores, exec) go taskSched.Run() log.Println(" ⏰ Task scheduler started (with executor)") } // 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(), }) }) // v0.32.0: Kubernetes probe endpoints // Liveness: process is alive and serving (no dependency checks). base.GET("/healthz/live", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) // Readiness: process can serve traffic (PG reachable). // Failing readiness pulls the pod from the Service — new requests // route to healthy replicas. base.GET("/healthz/ready", func(c *gin.Context) { if database.DB == nil { c.JSON(503, gin.H{"error": "database not initialized"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() if err := database.DB.PingContext(ctx); err != nil { c.JSON(503, gin.H{"error": "database unavailable"}) return } c.JSON(200, gin.H{"status": "ok"}) }) // v0.33.0: Prometheus metrics endpoint (no auth — Prometheus scrapes directly) base.GET("/metrics", gin.WrapH(promhttp.Handler())) // v0.33.0: OpenAPI spec + Swagger UI (no auth — documentation) base.GET("/api/docs", func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", swaggerHTML) }) base.GET("/api/docs/openapi.yaml", func(c *gin.Context) { // Replace ${VERSION} placeholder with actual version from VERSION file patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1) c.Data(http.StatusOK, "application/yaml", patched) }) // WebSocket endpoint base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── authMode, err := auth.ParseMode(cfg.AuthMode) if err != nil { log.Fatalf("❌ Invalid AUTH_MODE=%q: %v", cfg.AuthMode, err) } var authProvider auth.Provider switch authMode { case auth.ModeBuiltin: authProvider = auth.NewBuiltinProvider() case auth.ModeMTLS: authProvider = auth.NewMTLSProvider(auth.MTLSConfig{ HeaderDN: cfg.MTLSHeaderDN, HeaderVerify: cfg.MTLSHeaderVerify, HeaderFingerprint: cfg.MTLSHeaderFingerprint, AutoActivate: cfg.MTLSAutoActivate, DefaultTeam: cfg.MTLSDefaultTeam, DefaultRole: cfg.MTLSDefaultRole, }) case auth.ModeOIDC: var err error authProvider, err = auth.NewOIDCProvider(auth.OIDCConfig{ IssuerURL: cfg.OIDCIssuerURL, ExternalIssuerURL: cfg.OIDCExternalIssuerURL, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, RedirectURL: cfg.OIDCRedirectURL, AutoActivate: cfg.OIDCAutoActivate, DefaultTeam: cfg.OIDCDefaultTeam, DefaultRole: cfg.OIDCDefaultRole, RolesClaim: cfg.OIDCRolesClaim, GroupsClaim: cfg.OIDCGroupsClaim, AdminRole: cfg.OIDCAdminRole, }) if err != nil { log.Fatalf("❌ OIDC provider init failed: %v", err) } } log.Printf(" 🔑 Auth mode: %s", authMode) authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider) authLimiter := middleware.NewRateLimiter(stores.RateLimits, 5, 8) 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", authH.Register) authGroup.POST("/login", authH.Login) authGroup.POST("/refresh", authH.Refresh) authGroup.POST("/logout", authH.Logout) // OIDC routes (only active when AUTH_MODE=oidc) authGroup.GET("/oidc/login", authH.OIDCLogin) authGroup.GET("/oidc/callback", authH.OIDCCallback) } // ── 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, stores.Users, userCache)) protected.Use(middleware.ValidatePathParams()) { // ── WebSocket Ticket (v0.28.8) ─────────── // Issue a single-use ticket for WebSocket auth. // Client fetches this, then connects with ?ticket=. protected.POST("/ws/ticket", func(c *gin.Context) { userID := c.GetString("user_id") ticket, err := stores.Tickets.Issue(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"}) return } c.JSON(http.StatusOK, gin.H{"ticket": ticket}) }) // Channels channels := handlers.NewChannelHandler(stores) protected.GET("/channels", channels.ListChannels) protected.POST("/channels", middleware.RequirePermission(auth.PermChannelCreate, stores), channels.CreateChannel) protected.GET("/channels/:id", channels.GetChannel) protected.PUT("/channels/:id", channels.UpdateChannel) protected.DELETE("/channels/:id", channels.DeleteChannel) protected.POST("/channels/:id/mark-read", channels.MarkRead) // Typing indicator broadcast (v0.23.2) protected.POST("/channels/:id/typing", func(c *gin.Context) { userID := c.GetString("user_id") channelID := c.Param("id") // Resolve display name var displayName string user, err := stores.Users.GetByID(c.Request.Context(), userID) if err == nil && user != nil { displayName = user.DisplayName if displayName == "" { displayName = user.Username } } if displayName == "" { displayName = userID[:8] } // Broadcast to other user participants pids, err := stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, userID) if err == nil { payload, _ := json.Marshal(map[string]any{ "channel_id": channelID, "user_id": userID, "display_name": displayName, }) evt := events.Event{ Label: "typing.user", Payload: payload, Ts: time.Now().UnixMilli(), } for _, pid := range pids { hub.PublishToUser(pid, evt) } } c.JSON(200, gin.H{"ok": true}) }) // Chat Folders (v0.23.1) folders := handlers.NewFolderHandler(stores) 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) presence := handlers.NewPresenceHandler(stores) protected.POST("/presence/heartbeat", presence.Heartbeat) protected.GET("/presence", presence.Query) // User search (v0.23.2 — DM user picker) protected.GET("/users/search", presence.SearchUsers) // Persona groups (v0.23.2 — roster templates for group chats) pgH := handlers.NewPersonaGroupHandler(stores) protected.GET("/persona-groups", pgH.List) protected.POST("/persona-groups", pgH.Create) protected.GET("/persona-groups/:id", pgH.Get) protected.PUT("/persona-groups/:id", pgH.Update) protected.DELETE("/persona-groups/:id", pgH.Delete) protected.POST("/persona-groups/:id/members", pgH.AddMember) protected.DELETE("/persona-groups/:id/members/:memberId", pgH.RemoveMember) // Workflows (v0.26.1 — team-owned staged processes) wfH := handlers.NewWorkflowHandler(stores) protected.GET("/workflows", wfH.List) protected.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Create) protected.GET("/workflows/:id", wfH.Get) protected.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Update) protected.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Delete) protected.GET("/workflows/:id/stages", wfH.ListStages) protected.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.CreateStage) protected.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.UpdateStage) protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage) protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages) protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish) protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances (v0.26.2 — runtime lifecycle) wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) protected.POST("/channels/:id/workflow/reject", wfInstH.Reject) protected.POST("/channels/:id/workflow/cancel", wfInstH.CancelInstance) // v0.37.15 // Workflow assignments (v0.26.4 — team assignment queue) wfAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) protected.GET("/workflow-assignments/mine", wfAssignH.ListMine) protected.POST("/workflow-assignments/:id/claim", wfAssignH.Claim) protected.POST("/workflow-assignments/:id/complete", wfAssignH.Complete) protected.GET("/workflow-assignments/:id", wfAssignH.GetAssignment) protected.POST("/workflow-assignments/:id/comment", wfAssignH.CommentOnAssignment) protected.POST("/workflow-assignments/:id/unclaim", wfAssignH.Unclaim) // v0.37.15 protected.POST("/workflow-assignments/:id/reassign", wfAssignH.Reassign) // v0.37.15 protected.POST("/workflow-assignments/:id/cancel", wfAssignH.CancelAssignment) // v0.37.15 // Tasks (v0.27.1, permissions v0.27.2) taskH := handlers.NewTaskHandler(stores) protected.GET("/tasks", taskH.ListMine) protected.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Create) protected.GET("/tasks/:id", taskH.Get) protected.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Update) protected.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.Delete) protected.GET("/tasks/:id/runs", taskH.ListRuns) protected.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.RunNow) protected.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), taskH.KillRun) // 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", middleware.RequirePermission(auth.PermChannelInvite, stores), 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) protected.DELETE("/channels/:id/messages/:msgId", msgs.DeleteMessage) // Chat Completions comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) if healthAccum != nil { comp.SetHealthRecorder(healthAccum) comp.SetHealthStore(healthStore) } comp.SetRoutingEvaluator(routing.NewEvaluator()) comp.SetFilterChain(filterChain) comp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch protected.POST("/chat/completions", middleware.RequirePermission(auth.PermModelUse, stores), comp.Complete) protected.GET("/tools", comp.ListTools) // Surface discovery (v0.25.0, v0.28.7: unified packages) pkgH := handlers.NewPackageHandler(stores) protected.GET("/surfaces", pkgH.ListEnabledSurfaces) // User package management (v0.30.0) userPkgDir := "" if cfg.StoragePath != "" { userPkgDir = cfg.StoragePath + "/packages" } userPkgH := handlers.NewUserPackageHandler(stores, userPkgDir) protected.GET("/packages", userPkgH.ListVisiblePackages) protected.POST("/packages/install", userPkgH.InstallPersonalPackage) protected.DELETE("/packages/:id", userPkgH.DeletePersonalPackage) // Summarize & Continue (backed by compaction service) compactionSvc := compaction.NewService(stores, roleResolver) summarize := handlers.NewSummarizeHandler(stores, compactionSvc) protected.POST("/channels/:id/summarize", middleware.RequirePermission(auth.PermModelUse, stores), summarize.Summarize) // Auto-title generation (utility role) titleH := handlers.NewTitleHandler(stores, roleResolver) protected.POST("/channels/:id/generate-title", middleware.RequirePermission(auth.PermModelUse, stores), 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) // Connection Type Discovery (v0.38.4) connTypeH := handlers.NewConnectionTypeHandler(stores) protected.GET("/connection-types", connTypeH.ListConnectionTypes) // Extension Connections (personal scope — v0.38.1) connH := handlers.NewConnectionHandler(stores, keyResolver) protected.GET("/connections", connH.ListConnections) protected.POST("/connections", connH.CreateConnection) protected.GET("/connections/resolve", connH.ResolveConnection) protected.GET("/connections/:id", connH.GetConnection) protected.PUT("/connections/:id", connH.UpdateConnection) protected.DELETE("/connections/:id", connH.DeleteConnection) // 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(stores, 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) // Permission bootstrap (v0.37.1) — self-service resolved permissions permH := handlers.NewProfilePermissionsHandler(stores) protected.GET("/profile/permissions", permH.GetMyPermissions) // Boot payload (v0.37.15) — single-call SDK bootstrap bootH := handlers.NewProfileBootstrapHandler(stores) protected.GET("/profile/bootstrap", bootH.GetBootstrap) // Usage (personal) usage := handlers.NewUsageHandler(stores) protected.GET("/usage", usage.PersonalUsage) // Personas personas := handlers.NewPersonaHandler(stores) protected.GET("/personas", personas.ListUserPersonas) protected.POST("/personas", middleware.RequirePermission(auth.PermPersonaCreate, stores), personas.CreateUserPersona) protected.PUT("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.UpdateUserPersona) protected.DELETE("/personas/:id", middleware.RequirePermission(auth.PermPersonaManage, stores), personas.DeleteUserPersona) protected.POST("/personas/:id/avatar", personas.UploadUserPersonaAvatar) protected.DELETE("/personas/:id/avatar", personas.DeleteUserPersonaAvatar) protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0 protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0 protected.GET("/personas/:id/tool-grants", personas.GetPersonaToolGrants) // v0.25.0 protected.PUT("/personas/:id/tool-grants", personas.SetPersonaToolGrants) // v0.25.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, reworked v0.37.17 — workspace-backed) projFileH := handlers.NewFileHandler(stores, objStore, extQueue) if wfs != nil { projFileH.SetWorkspaceFS(wfs) } protected.POST("/projects/:id/files", projFileH.UploadToProject) protected.GET("/projects/:id/files", projFileH.ListByProject) protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile) protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile) protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject) protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive) protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive) // 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/default", wsH.GetDefault) // v0.37.18: before :id param 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.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) // 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", fileH.ListByUser) protected.GET("/files/:id", fileH.GetMetadata) protected.GET("/files/:id/download", fileH.Download) protected.DELETE("/files/:id", fileH.Delete) protected.GET("/messages/:id/files", fileH.ListByMessage) // Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion exportH := handlers.NewExportHandler() protected.POST("/export", exportH.Convert) // Data portability (v0.34.0) — user data export/import dataExport := handlers.NewDataExportHandler(stores, objStore) protected.GET("/export/me", dataExport.ExportMyData) dataImport := handlers.NewDataImportHandler(stores, objStore) protected.POST("/import/me", dataImport.ImportMyData) protected.POST("/import/chatgpt", dataImport.ImportChatGPT) gdprH := handlers.NewGDPRHandler(stores) protected.DELETE("/me", gdprH.DeleteMyAccount) // 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", middleware.RequirePermission(auth.PermKBCreate, stores), kbH.CreateKB) protected.GET("/knowledge-bases", kbH.ListKBs) protected.GET("/knowledge-bases/:id", kbH.GetKB) protected.PUT("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.UpdateKB) protected.DELETE("/knowledge-bases/:id", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteKB) protected.POST("/knowledge-bases/:id/documents", middleware.RequirePermission(auth.PermKBWrite, stores), 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", middleware.RequirePermission(auth.PermKBWrite, stores), kbH.DeleteDocument) protected.POST("/knowledge-bases/:id/search", middleware.RequirePermission(auth.PermKBRead, stores), kbH.SearchKB) protected.POST("/knowledge-bases/:id/rebuild", middleware.RequirePermission(auth.PermKBWrite, stores), 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) memH.SetCompactor(memCompactor) 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) protected.POST("/memories/compact", memH.CompactMemories) // Teams (user: my teams) teams := handlers.NewTeamHandler(stores, 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(stores.Teams)) { 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 connections (v0.38.1) teamScoped.GET("/connections", teams.ListTeamConnections) teamScoped.POST("/connections", teams.CreateTeamConnection) teamScoped.PUT("/connections/:id", teams.UpdateTeamConnection) teamScoped.DELETE("/connections/:id", teams.DeleteTeamConnection) // Team package management (v0.30.0) teamPkgH := handlers.NewUserPackageHandler(stores, userPkgDir) teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage) teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage) // 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.PUT("/personas/:id", teamPersonas.UpdateTeamPersona) teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona) teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetTeamPersonaKBs) // v0.17.0 teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetTeamPersonaKBs) // v0.17.0 teamScoped.GET("/personas/:id/tool-grants", teamPersonas.GetTeamPersonaToolGrants) // v0.28.0 teamScoped.PUT("/personas/:id/tool-grants", teamPersonas.SetTeamPersonaToolGrants) // v0.28.0 teamScoped.POST("/personas/:id/avatar", teamPersonas.UploadTeamPersonaAvatar) // v0.28.0 teamScoped.DELETE("/personas/:id/avatar", teamPersonas.DeleteTeamPersonaAvatar) // v0.28.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) // Team workflow assignments (v0.26.4) teamAssignH := handlers.NewWorkflowAssignmentHandler(stores, hub) teamScoped.GET("/assignments", teamAssignH.ListForTeam) // Team workflows — self-service (v0.31.2) teamWfH := handlers.NewWorkflowHandler(stores) teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows) teamScoped.POST("/workflows", teamWfH.CreateTeamWorkflow) teamScoped.GET("/workflows/:id", teamWfH.GetTeamWorkflow) teamScoped.PATCH("/workflows/:id", teamWfH.UpdateTeamWorkflow) teamScoped.DELETE("/workflows/:id", teamWfH.DeleteTeamWorkflow) teamScoped.GET("/workflows/:id/stages", teamWfH.ListTeamWorkflowStages) teamScoped.POST("/workflows/:id/stages", teamWfH.CreateTeamWorkflowStage) teamScoped.PUT("/workflows/:id/stages/:sid", teamWfH.UpdateTeamWorkflowStage) teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage) teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages) teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow) teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion) // Team workflow monitoring (v0.35.0) teamWfMon := handlers.NewWorkflowMonitorHandler(stores) teamScoped.GET("/workflows/monitor/instances", teamWfMon.ListTeamActiveInstances) // Team workflow instance cancel (v0.37.15) teamWfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc, starlarkRunner) teamScoped.POST("/workflows/monitor/instances/:channelId/cancel", teamWfInstH.CancelTeamInstance) // Team tasks — admin CRUD (v0.27.5) teamTaskH := handlers.NewTaskHandler(stores) teamScoped.POST("/tasks", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.CreateTeamTask) teamScoped.PUT("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Update) teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete) teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow) teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun) } // Team task viewing for all members (v0.27.5) teamMemberRoutes := protected.Group("/teams/:teamId") teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams)) { teamMemberTaskH := handlers.NewTaskHandler(stores) teamMemberRoutes.GET("/tasks", teamMemberTaskH.ListTeamTasks) teamMemberRoutes.GET("/tasks/:id/runs", teamMemberTaskH.ListRuns) } // Public global settings (non-admin users can read safe subset) adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) 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, stores.Users, userCache)) admin.Use(middleware.RequireAdmin()) admin.Use(middleware.ValidatePathParams()) { adm := handlers.NewAdminHandler(stores, keyResolver, uekCache, objStore) adm.OnUserChanged(userCache.Evict) // 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) // Global Connections (v0.38.1) admin.GET("/connections", adm.ListGlobalConnections) admin.POST("/connections", adm.CreateGlobalConnection) admin.PUT("/connections/:id", adm.UpdateGlobalConnection) admin.DELETE("/connections/:id", adm.DeleteGlobalConnection) // 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", func(c *gin.Context) { handlers.UploadPersonaAvatar(stores.Personas, c) }) admin.DELETE("/personas/:id/avatar", func(c *gin.Context) { handlers.DeletePersonaAvatar(stores.Personas, c) }) admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0 admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0 admin.GET("/personas/:id/tool-grants", personaAdm.GetPersonaToolGrants) // v0.25.0 admin.PUT("/personas/:id/tool-grants", personaAdm.SetPersonaToolGrants) // v0.25.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(stores, 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) // Team data export/import (v0.34.0) teamExport := handlers.NewDataExportHandler(stores, objStore) teamImport := handlers.NewDataImportHandler(stores, objStore) admin.GET("/teams/:id/export", teamExport.ExportTeam) admin.POST("/teams/:id/import", teamImport.ImportTeam) // Admin broadcast (v0.28.6) adminNotifH := handlers.NewNotificationHandler(stores, hub) admin.POST("/notifications/broadcast", adminNotifH.Broadcast) // 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) // Permissions (v0.24.2) admin.GET("/permissions", groupAdm.ListPermissions) admin.GET("/users/:id/permissions", groupAdm.GetUserPermissions) // 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) // Archived channels (admin — v0.23.2) admin.GET("/channels/archived", adm.ListArchivedChannels) admin.DELETE("/channels/:id/purge", adm.PurgeChannel) // 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) // Extension permissions (admin — v0.29.0) extPermH := handlers.NewExtPermHandler(stores) admin.GET("/extensions/:id/permissions", extPermH.ListPackagePermissions) admin.GET("/extensions/:id/review", extPermH.ReviewPackage) admin.POST("/extensions/:id/permissions/:perm/grant", extPermH.GrantPermission) admin.POST("/extensions/:id/permissions/:perm/revoke", extPermH.RevokePermission) admin.POST("/extensions/:id/permissions/grant-all", extPermH.GrantAllPermissions) // Extension secrets (admin — v0.29.0 CS3) extSecH := handlers.NewExtSecretsHandler(stores) admin.GET("/extensions/:id/secrets", extSecH.GetSecrets) admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets) admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets) // Admin Dashboard (v0.33.0) dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub) admin.GET("/dashboard", dashAdm.GetDashboard) // 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) // Package lifecycle management (v0.28.7 — replaces surfaces + extensions admin) packagesDir := "" if cfg.StoragePath != "" { packagesDir = cfg.StoragePath + "/packages" } pkgAdm := handlers.NewPackageHandler(stores, packagesDir) pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool // Package registry — must be registered before /packages/:id (v0.30.0) registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) admin.GET("/packages/registry", registryH.BrowseRegistry) admin.POST("/packages/registry/install", registryH.InstallFromRegistry) admin.GET("/packages", pkgAdm.ListPackages) admin.GET("/packages/:id", pkgAdm.GetPackage) admin.POST("/packages/install", pkgAdm.InstallPackage) admin.PUT("/packages/:id/enable", pkgAdm.EnablePackage) admin.PUT("/packages/:id/disable", pkgAdm.DisablePackage) admin.DELETE("/packages/:id", pkgAdm.DeletePackage) admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0 admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0 admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2 admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2 admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2 admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2 // Package export (v0.30.0) pkgExport := handlers.NewPackageExportHandler(stores, packagesDir) admin.GET("/packages/:id/export", pkgExport.ExportPackage) // Workflow package export (v0.30.2) wfPkgH := handlers.NewWorkflowPackageHandler(stores) admin.GET("/workflows/:id/export", wfPkgH.ExportWorkflowPackage) // Workflow monitoring (v0.35.0) wfMonH := handlers.NewWorkflowMonitorHandler(stores) admin.GET("/workflows/monitor/instances", wfMonH.ListActiveInstances) admin.GET("/workflows/monitor/funnel/:id", wfMonH.GetFunnel) admin.GET("/workflows/monitor/stale", wfMonH.ListStaleInstances) // Surface aliases (backward compat — same handlers) admin.GET("/surfaces", pkgAdm.ListPackages) admin.GET("/surfaces/:id", pkgAdm.GetPackage) admin.POST("/surfaces/install", pkgAdm.InstallPackage) admin.PUT("/surfaces/:id/enable", pkgAdm.EnablePackage) admin.PUT("/surfaces/:id/disable", pkgAdm.DisablePackage) admin.DELETE("/surfaces/:id", pkgAdm.DeletePackage) // Task management — admin (v0.27.1, extended v0.27.2) taskAdm := handlers.NewTaskHandler(stores) admin.GET("/tasks", taskAdm.ListAll) admin.POST("/tasks/:id/run", taskAdm.RunNow) admin.POST("/tasks/:id/kill", taskAdm.KillRun) admin.DELETE("/tasks/:id", taskAdm.Delete) admin.GET("/system-functions", taskAdm.ListSystemFunctions) } } // ── 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. // v0.27.0: Extension surface static assets (JS, CSS, images). // Served without auth — same rationale as extension assets (script tags can't send headers). // v0.28.7: on-disk path moved to /data/packages/, URL stays /surfaces/:id/ for stability. // In split deployment, nginx serves these from /data/packages/ directly. if cfg.StoragePath != "" { packagesStaticDir := cfg.StoragePath + "/packages" base.GET("/surfaces/:id/*path", func(c *gin.Context) { id := c.Param("id") filePath := c.Param("path") // Security: prevent path traversal clean := filepath.Clean(filepath.Join(packagesStaticDir, id, filePath)) if !strings.HasPrefix(clean, filepath.Clean(packagesStaticDir)) { c.Status(http.StatusForbidden) return } c.File(clean) }) } pages.SetVersion(Version) pageEngine := pages.New(cfg, stores) // Login page — no auth required base.GET("/login", pageEngine.RenderLogin()) // v0.25.0: Surface routes generated from manifest registry. // Replaces manual per-surface route blocks. pageEngine.RegisterPageRoutes(base, pages.PageRouteMiddleware{ Authenticated: middleware.AuthOrRedirect(cfg, stores.Users, userCache), Admin: []gin.HandlerFunc{ middleware.AuthOrRedirect(cfg, stores.Users, userCache), middleware.RequireAdminPage(), }, Session: middleware.AuthOrSession(cfg, stores, userCache), }) // v0.29.1: Extension API routes — Starlark packages serve JSON endpoints. // Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect). { extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner) extAPI := base.Group("/s/:slug/api") extAPI.Use(middleware.Auth(cfg, stores.Users, userCache)) extAPI.Any("/*path", extAPIH.Handle) } // Workflow API — session participants can send messages + trigger completions wfAPI := base.Group("/api/v1/w") wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache)) { wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore) wfAPI.POST("/:id/messages", wfMsgs.CreateMessage) wfAPI.GET("/:id/messages", wfMsgs.ListMessages) wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder) wfComp.SetFilterChain(filterChain) wfComp.SetRunner(starlarkRunner) // v0.29.2: extension tool dispatch wfAPI.POST("/:id/completions", wfComp.Complete) // v0.29.3: Workflow form endpoints wfForms := handlers.NewWorkflowFormHandler(stores, starlarkRunner, hub) wfAPI.GET("/:id/form", wfForms.GetFormTemplate) wfAPI.POST("/:id/form-submit", wfForms.SubmitForm) } // Workflow visitor entry (v0.26.3) — public start endpoint // NOTE: Cannot use /api/v1/w/:scope/:slug/start — Gin's radix trie // conflicts with /api/v1/w/:id/messages (different wildcard names at // same path position). Separate namespace avoids the collision. wfEntry := handlers.NewWorkflowEntryHandler(stores) base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor) // v0.28.0: Webhook trigger endpoint (token-based auth, no JWT) triggerH := handlers.NewTriggerHandler(stores) base.POST("/api/v1/hooks/t/:token", triggerH.Handle) 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 (%d surfaces registered)", len(pageEngine.Surfaces())) 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 \n") os.Exit(1) } }