ScuttleBot

scuttlebot / internal / api / server.go
Source Blame History 156 lines
2d8a379… lmata 1 // Package api implements the scuttlebot HTTP management API.
2d8a379… lmata 2 //
d74d207… lmata 3 // /v1/ endpoints require a valid Bearer token.
d74d207… lmata 4 // /ui/ is served unauthenticated (static web UI).
d74d207… lmata 5 // /v1/channels/{channel}/stream uses ?token= query param (EventSource limitation).
2d8a379… lmata 6 package api
2d8a379… lmata 7
2d8a379… lmata 8 import (
2d8a379… lmata 9 "log/slog"
2d8a379… lmata 10 "net/http"
2d8a379… lmata 11
68677f9… noreply 12 "github.com/conflicthq/scuttlebot/internal/auth"
24a217e… lmata 13 "github.com/conflicthq/scuttlebot/internal/config"
2d8a379… lmata 14 "github.com/conflicthq/scuttlebot/internal/registry"
2d8a379… lmata 15 )
2d8a379… lmata 16
2d8a379… lmata 17 // Server is the scuttlebot HTTP API server.
2d8a379… lmata 18 type Server struct {
24a217e… lmata 19 registry *registry.Registry
68677f9… noreply 20 apiKeys *auth.APIKeyStore
24a217e… lmata 21 log *slog.Logger
24a217e… lmata 22 bridge chatBridge // nil if bridge is disabled
24a217e… lmata 23 policies *PolicyStore // nil if not configured
24a217e… lmata 24 admins adminStore // nil if not configured
24a217e… lmata 25 llmCfg *config.LLMConfig // nil if no LLM backends configured
dd3a887… lmata 26 topoMgr topologyManager // nil if topology not configured
17e2c1d… lmata 27 cfgStore *ConfigStore // nil if config write-back not configured
24a217e… lmata 28 loginRL *loginRateLimiter
24a217e… lmata 29 tlsDomain string // empty if no TLS
d74d207… lmata 30 }
d74d207… lmata 31
d74d207… lmata 32 // New creates a new API Server. Pass nil for b to disable the chat bridge.
24a217e… lmata 33 // Pass nil for admins to disable admin authentication endpoints.
24a217e… lmata 34 // Pass nil for llmCfg to disable AI/LLM management endpoints.
dd3a887… lmata 35 // Pass nil for topo to disable topology provisioning endpoints.
17e2c1d… lmata 36 // Pass nil for cfgStore to disable config read/write endpoints.
68677f9… noreply 37 func New(reg *registry.Registry, apiKeys *auth.APIKeyStore, b chatBridge, ps *PolicyStore, admins adminStore, llmCfg *config.LLMConfig, topo topologyManager, cfgStore *ConfigStore, tlsDomain string, log *slog.Logger) *Server {
d74d207… lmata 38 return &Server{
24a217e… lmata 39 registry: reg,
68677f9… noreply 40 apiKeys: apiKeys,
24a217e… lmata 41 log: log,
24a217e… lmata 42 bridge: b,
24a217e… lmata 43 policies: ps,
24a217e… lmata 44 admins: admins,
24a217e… lmata 45 llmCfg: llmCfg,
dd3a887… lmata 46 topoMgr: topo,
17e2c1d… lmata 47 cfgStore: cfgStore,
24a217e… lmata 48 loginRL: newLoginRateLimiter(),
24a217e… lmata 49 tlsDomain: tlsDomain,
2d8a379… lmata 50 }
2d8a379… lmata 51 }
2d8a379… lmata 52
2d8a379… lmata 53 // Handler returns the HTTP handler with all routes registered.
21649aa… lmata 54 // /v1/ routes require a valid Bearer token. /ui/ is served unauthenticated.
68677f9… noreply 55 // Scoped routes additionally check the API key's scopes.
2d8a379… lmata 56 func (s *Server) Handler() http.Handler {
21649aa… lmata 57 apiMux := http.NewServeMux()
68677f9… noreply 58
68677f9… noreply 59 // Read-scope: status, metrics (also accessible with any scope via admin).
68677f9… noreply 60 apiMux.HandleFunc("GET /v1/status", s.requireScope(auth.ScopeRead, s.handleStatus))
68677f9… noreply 61 apiMux.HandleFunc("GET /v1/metrics", s.requireScope(auth.ScopeRead, s.handleMetrics))
68677f9… noreply 62
68677f9… noreply 63 // Policies — admin scope.
68677f9… noreply 64 if s.policies != nil {
68677f9… noreply 65 apiMux.HandleFunc("GET /v1/settings", s.requireScope(auth.ScopeRead, s.handleGetSettings))
68677f9… noreply 66 apiMux.HandleFunc("GET /v1/settings/policies", s.requireScope(auth.ScopeRead, s.handleGetPolicies))
68677f9… noreply 67 apiMux.HandleFunc("PUT /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePutPolicies))
68677f9… noreply 68 apiMux.HandleFunc("PATCH /v1/settings/policies", s.requireScope(auth.ScopeAdmin, s.handlePatchPolicies))
68677f9… noreply 69 }
68677f9… noreply 70
68677f9… noreply 71 // Agents — agents scope.
68677f9… noreply 72 apiMux.HandleFunc("GET /v1/agents", s.requireScope(auth.ScopeAgents, s.handleListAgents))
68677f9… noreply 73 apiMux.HandleFunc("GET /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleGetAgent))
68677f9… noreply 74 apiMux.HandleFunc("PATCH /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleUpdateAgent))
68677f9… noreply 75 apiMux.HandleFunc("POST /v1/agents/register", s.requireScope(auth.ScopeAgents, s.handleRegister))
68677f9… noreply 76 apiMux.HandleFunc("POST /v1/agents/{nick}/rotate", s.requireScope(auth.ScopeAgents, s.handleRotate))
68677f9… noreply 77 apiMux.HandleFunc("POST /v1/agents/{nick}/adopt", s.requireScope(auth.ScopeAgents, s.handleAdopt))
68677f9… noreply 78 apiMux.HandleFunc("POST /v1/agents/{nick}/revoke", s.requireScope(auth.ScopeAgents, s.handleRevoke))
68677f9… noreply 79 apiMux.HandleFunc("DELETE /v1/agents/{nick}", s.requireScope(auth.ScopeAgents, s.handleDelete))
50ba2ec… noreply 80 apiMux.HandleFunc("POST /v1/agents/bulk-delete", s.requireScope(auth.ScopeAgents, s.handleBulkDeleteAgents))
68677f9… noreply 81
68677f9… noreply 82 // Channels — channels scope (read), chat scope (send).
68677f9… noreply 83 if s.bridge != nil {
68677f9… noreply 84 apiMux.HandleFunc("GET /v1/channels", s.requireScope(auth.ScopeChannels, s.handleListChannels))
68677f9… noreply 85 apiMux.HandleFunc("POST /v1/channels/{channel}/join", s.requireScope(auth.ScopeChannels, s.handleJoinChannel))
68677f9… noreply 86 apiMux.HandleFunc("DELETE /v1/channels/{channel}", s.requireScope(auth.ScopeChannels, s.handleDeleteChannel))
68677f9… noreply 87 apiMux.HandleFunc("GET /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChannels, s.handleChannelMessages))
68677f9… noreply 88 apiMux.HandleFunc("POST /v1/channels/{channel}/messages", s.requireScope(auth.ScopeChat, s.handleSendMessage))
68677f9… noreply 89 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.requireScope(auth.ScopeChat, s.handleChannelPresence))
68677f9… noreply 90 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.requireScope(auth.ScopeChannels, s.handleChannelUsers))
a027855… noreply 91 apiMux.HandleFunc("GET /v1/channels/{channel}/config", s.requireScope(auth.ScopeChannels, s.handleGetChannelConfig))
a027855… noreply 92 apiMux.HandleFunc("PUT /v1/channels/{channel}/config", s.requireScope(auth.ScopeAdmin, s.handlePutChannelConfig))
68677f9… noreply 93 }
68677f9… noreply 94
68677f9… noreply 95 // Topology — topology scope.
68677f9… noreply 96 if s.topoMgr != nil {
68677f9… noreply 97 apiMux.HandleFunc("POST /v1/channels", s.requireScope(auth.ScopeTopology, s.handleProvisionChannel))
68677f9… noreply 98 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.requireScope(auth.ScopeTopology, s.handleDropChannel))
68677f9… noreply 99 apiMux.HandleFunc("GET /v1/topology", s.requireScope(auth.ScopeTopology, s.handleGetTopology))
68677f9… noreply 100 }
ba75f34… noreply 101 // Blocker escalation — agents can signal they're stuck.
ba75f34… noreply 102 if s.bridge != nil {
ba75f34… noreply 103 apiMux.HandleFunc("POST /v1/agents/{nick}/blocker", s.requireScope(auth.ScopeAgents, s.handleAgentBlocker))
ba75f34… noreply 104 }
ba75f34… noreply 105
ba75f34… noreply 106 // Instructions — available even without topology (uses policies store).
ba75f34… noreply 107 apiMux.HandleFunc("GET /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleGetInstructions))
ba75f34… noreply 108 apiMux.HandleFunc("PUT /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handlePutInstructions))
ba75f34… noreply 109 apiMux.HandleFunc("DELETE /v1/channels/{channel}/instructions", s.requireScope(auth.ScopeTopology, s.handleDeleteInstructions))
68677f9… noreply 110
68677f9… noreply 111 // Config — config scope.
68677f9… noreply 112 if s.cfgStore != nil {
68677f9… noreply 113 apiMux.HandleFunc("GET /v1/config", s.requireScope(auth.ScopeConfig, s.handleGetConfig))
68677f9… noreply 114 apiMux.HandleFunc("PUT /v1/config", s.requireScope(auth.ScopeConfig, s.handlePutConfig))
68677f9… noreply 115 apiMux.HandleFunc("GET /v1/config/history", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistory))
68677f9… noreply 116 apiMux.HandleFunc("GET /v1/config/history/{filename}", s.requireScope(auth.ScopeConfig, s.handleGetConfigHistoryEntry))
68677f9… noreply 117 }
68677f9… noreply 118
68677f9… noreply 119 // Admin — admin scope.
68677f9… noreply 120 if s.admins != nil {
68677f9… noreply 121 apiMux.HandleFunc("GET /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminList))
68677f9… noreply 122 apiMux.HandleFunc("POST /v1/admins", s.requireScope(auth.ScopeAdmin, s.handleAdminAdd))
68677f9… noreply 123 apiMux.HandleFunc("DELETE /v1/admins/{username}", s.requireScope(auth.ScopeAdmin, s.handleAdminRemove))
68677f9… noreply 124 apiMux.HandleFunc("PUT /v1/admins/{username}/password", s.requireScope(auth.ScopeAdmin, s.handleAdminSetPassword))
68677f9… noreply 125 }
68677f9… noreply 126
68677f9… noreply 127 // API key management — admin scope.
68677f9… noreply 128 apiMux.HandleFunc("GET /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleListAPIKeys))
68677f9… noreply 129 apiMux.HandleFunc("POST /v1/api-keys", s.requireScope(auth.ScopeAdmin, s.handleCreateAPIKey))
68677f9… noreply 130 apiMux.HandleFunc("DELETE /v1/api-keys/{id}", s.requireScope(auth.ScopeAdmin, s.handleRevokeAPIKey))
68677f9… noreply 131
68677f9… noreply 132 // LLM / AI gateway — bots scope.
68677f9… noreply 133 apiMux.HandleFunc("GET /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackends))
68677f9… noreply 134 apiMux.HandleFunc("POST /v1/llm/backends", s.requireScope(auth.ScopeBots, s.handleLLMBackendCreate))
68677f9… noreply 135 apiMux.HandleFunc("PUT /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendUpdate))
68677f9… noreply 136 apiMux.HandleFunc("DELETE /v1/llm/backends/{name}", s.requireScope(auth.ScopeBots, s.handleLLMBackendDelete))
68677f9… noreply 137 apiMux.HandleFunc("GET /v1/llm/backends/{name}/models", s.requireScope(auth.ScopeBots, s.handleLLMModels))
68677f9… noreply 138 apiMux.HandleFunc("POST /v1/llm/discover", s.requireScope(auth.ScopeBots, s.handleLLMDiscover))
68677f9… noreply 139 apiMux.HandleFunc("GET /v1/llm/known", s.requireScope(auth.ScopeBots, s.handleLLMKnown))
68677f9… noreply 140 apiMux.HandleFunc("POST /v1/llm/complete", s.requireScope(auth.ScopeBots, s.handleLLMComplete))
24a217e… lmata 141
21649aa… lmata 142 outer := http.NewServeMux()
24a217e… lmata 143 outer.HandleFunc("POST /login", s.handleLogin)
21649aa… lmata 144 outer.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
21649aa… lmata 145 http.Redirect(w, r, "/ui/", http.StatusFound)
21649aa… lmata 146 })
21649aa… lmata 147 outer.Handle("/ui/", s.uiFileServer())
d74d207… lmata 148 // SSE stream uses ?token= auth (EventSource can't send headers), registered
d74d207… lmata 149 // on outer so it bypasses the Bearer-token authMiddleware on /v1/.
d74d207… lmata 150 if s.bridge != nil {
d74d207… lmata 151 outer.HandleFunc("GET /v1/channels/{channel}/stream", s.handleChannelStream)
d74d207… lmata 152 }
21649aa… lmata 153 outer.Handle("/v1/", s.authMiddleware(apiMux))
21649aa… lmata 154
21649aa… lmata 155 return outer
2d8a379… lmata 156 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button