Removed all references to dropped packages in non-test code.
Zero dropped imports, store refs, or model types remaining.
Major removals:
- scheduler/ package (entire dir) — tasks moved to extension track
- taskutil/ package (entire dir)
- health/ package (entire dir) — provider/tool health
- sandbox/provider_module.go — provider.complete module
- handlers: workflow_entry, workflow_instances, workflow_forms,
workflow_assignments, workflow_monitor, health_admin (6 files)
- auth/session.go, middleware/session_auth.go
- store/{postgres,sqlite}/sessions.go, tasks.go
Rewrites:
- store/{postgres,sqlite}/health.go — kernel-only Prune
(ws_tickets, rate_limit_counters, stale presence)
- handlers/admin.go: 847→467 lines (stripped provider CRUD)
- handlers/teams.go: 520→411 lines (stripped model listing)
- sandbox/runner.go: removed ProviderResolver
- sandbox/workflow_module.go: 216→83 lines (definition-only)
- main.go: 1579→1325 lines (stripped dropped package init)
- pages/pages.go: stripped channel/session lookups
- events/types.go: stripped chat/channel/workspace event routes
- models: stripped stale types, constants, notification types
Store interface cleanup:
- Removed SessionStore, TaskStore from Stores struct
- Stripped workflow assignment methods from WorkflowStore iface
- Stripped assignment methods from PG+SQLite workflow stores
- Updated testhelper.go table list to kernel-only
Migrations: removed 008_tasks.sql (both dialects)
-9665/+69 lines across 51 files.
1105 lines
43 KiB
Go
1105 lines
43 KiB
Go
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"
|
|
|
|
"switchboard-core/auth"
|
|
"switchboard-core/config"
|
|
"switchboard-core/crypto"
|
|
"switchboard-core/database"
|
|
"switchboard-core/events"
|
|
"switchboard-core/sandbox"
|
|
"switchboard-core/handlers"
|
|
"switchboard-core/logging"
|
|
"switchboard-core/metrics"
|
|
"switchboard-core/middleware"
|
|
"switchboard-core/notifications"
|
|
"switchboard-core/pages"
|
|
"switchboard-core/storage"
|
|
"switchboard-core/store"
|
|
postgres "switchboard-core/store/postgres"
|
|
sqliteStore "switchboard-core/store/sqlite"
|
|
)
|
|
|
|
// 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)
|
|
|
|
|
|
// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
|
|
if 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
|
|
|
|
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)
|
|
}
|
|
|
|
|
|
// Provider health accumulator (v0.22.0)
|
|
if database.IsSQLite() {
|
|
} else {
|
|
}
|
|
|
|
// v0.33.0: Start Prometheus DB pool collector
|
|
metrics.StartDBCollector(database.DB, 15*time.Second)
|
|
|
|
// Kernel maintenance: prune stale data every 6 hours
|
|
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 := stores.Health.Prune(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil {
|
|
log.Printf("⚠ maintenance prune failed: %v", err)
|
|
} else if n > 0 {
|
|
log.Printf("🧹 maintenance: pruned %d stale rows", n)
|
|
}
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
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()
|
|
|
|
// 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)
|
|
|
|
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
|
|
// Nil if storage is disabled.
|
|
if objStore != nil && cfg.StoragePath != "" {
|
|
if err != nil {
|
|
log.Printf("⚠ Extraction queue init failed: %v", err)
|
|
} else {
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
|
|
if cfg.StoragePath != "" {
|
|
if err := wfs.Init(); err != nil {
|
|
log.Printf("⚠ Workspace FS init failed: %v", err)
|
|
} else {
|
|
log.Printf(" 📁 Workspace FS initialized at %s/workspaces", cfg.StoragePath)
|
|
}
|
|
}
|
|
|
|
|
|
// 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.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")
|
|
}
|
|
|
|
|
|
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
|
|
|
|
// 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(),
|
|
}
|
|
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=<opaque>.
|
|
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
|
|
|
|
// 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]
|
|
}
|
|
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)
|
|
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)
|
|
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 assignments (v0.26.4 — team assignment queue)
|
|
|
|
// Tasks (v0.27.1, permissions v0.27.2)
|
|
|
|
// Channel models (v0.20.0 — multi-model @mention routing)
|
|
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)
|
|
|
|
// Messages
|
|
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)
|
|
|
|
// 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)
|
|
|
|
|
|
|
|
// Provider Configs (user-facing — replaces /api-configs)
|
|
|
|
// 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)
|
|
|
|
// Model Preferences
|
|
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)
|
|
protected.GET("/usage", usage.PersonalUsage)
|
|
|
|
// Personas
|
|
|
|
// Notes
|
|
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)
|
|
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)
|
|
|
|
// Git credentials (v0.21.4) — user-scoped, independent of workspace
|
|
|
|
|
|
// Export (v0.22.4) — pandoc-based markdown → PDF/DOCX conversion
|
|
|
|
// Data portability (v0.34.0) — user data export/import
|
|
protected.GET("/export/me", dataExport.ExportMyData)
|
|
protected.POST("/import/me", dataImport.ImportMyData)
|
|
protected.POST("/import/chatgpt", dataImport.ImportChatGPT)
|
|
|
|
// Hook: clean up storage files when channels are deleted
|
|
|
|
|
|
// Memory management (v0.18.0)
|
|
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)
|
|
|
|
// 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)
|
|
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
|
|
|
// Team personas
|
|
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
|
|
|
|
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
|
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
|
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
|
|
|
// Team workflow assignments (v0.26.4)
|
|
|
|
// 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)
|
|
|
|
|
|
// Team tasks — admin CRUD (v0.27.5)
|
|
}
|
|
|
|
// Team task viewing for all members (v0.27.5)
|
|
teamMemberRoutes := protected.Group("/teams/:teamId")
|
|
teamMemberRoutes.Use(middleware.RequireTeamMember(stores.Teams))
|
|
{
|
|
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
|
|
|
|
// 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
|
|
|
|
// Personas (admin global)
|
|
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)
|
|
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)
|
|
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)
|
|
admin.GET("/projects", adminProjH.AdminList)
|
|
admin.DELETE("/projects/:id", adminProjH.Delete)
|
|
admin.DELETE("/grants/:type/:id", groupAdm.DeleteResourceGrant)
|
|
|
|
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
|
|
|
|
// 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)
|
|
|
|
admin.GET("/storage/orphans", fileAdm.OrphanCount)
|
|
admin.POST("/storage/cleanup", fileAdm.CleanupOrphans)
|
|
admin.GET("/storage/extraction", fileAdm.ExtractionStatus)
|
|
|
|
// Archived channels (admin — v0.23.2)
|
|
|
|
// 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.GET("/dashboard", dashAdm.GetDashboard)
|
|
|
|
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)
|
|
admin.GET("/providers/:id/health", healthAdm.GetProviderHealth)
|
|
|
|
// Capability Overrides (admin — v0.22.0)
|
|
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)
|
|
|
|
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
|
|
|
|
// 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)
|
|
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)
|
|
|
|
// 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)
|
|
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))
|
|
{
|
|
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
|
|
wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
|
|
|
|
wfAPI.POST("/:id/completions", wfComp.Complete)
|
|
|
|
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.
|
|
base.POST("/api/v1/workflow-entry/:scope/:slug", wfEntry.StartVisitor)
|
|
|
|
// v0.28.0: Webhook trigger endpoint (token-based auth, no JWT)
|
|
|
|
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())
|
|
if objStore != nil {
|
|
log.Printf(" Storage: %s", objStore.Backend())
|
|
} else {
|
|
log.Printf(" Storage: disabled")
|
|
}
|
|
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
|
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 runVaultCommand(subcmd string) {
|
|
cfg := config.Load()
|
|
|
|
switch strings.ToLower(subcmd) {
|
|
case "rekey":
|
|
if cfg.DatabaseURL == "" {
|
|
log.Fatal("DATABASE_URL is required for vault operations")
|
|
}
|
|
if err := database.Connect(cfg); err != nil {
|
|
log.Fatalf("❌ Database connection failed: %v", err)
|
|
}
|
|
defer database.Close()
|
|
|
|
oldKey := os.Getenv("ENCRYPTION_KEY")
|
|
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
|
|
|
|
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
|
|
log.Fatalf("❌ Vault rekey failed: %v", err)
|
|
}
|
|
|
|
case "status":
|
|
if cfg.DatabaseURL == "" {
|
|
log.Fatal("DATABASE_URL is required for vault operations")
|
|
}
|
|
if err := database.Connect(cfg); err != nil {
|
|
log.Fatalf("❌ Database connection failed: %v", err)
|
|
}
|
|
defer database.Close()
|
|
|
|
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
|
|
if err != nil {
|
|
log.Fatalf("❌ Failed to get vault status: %v", err)
|
|
}
|
|
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
|
|
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
|
|
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
|
|
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
|
|
os.Exit(1)
|
|
}
|
|
}
|